vue3-django 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +92 -0
  2. package/dist/vue3-django.css +1 -0
  3. package/dist/vue3-django.js +4439 -0
  4. package/docs/iteration-01-preview.jpg +0 -0
  5. package/docs/iterations.md +37 -0
  6. package/docs/migration.md +133 -0
  7. package/docs/verification.md +25 -0
  8. package/package.json +53 -0
  9. package/src/components/form/Field.vue +113 -0
  10. package/src/components/form/Form.js +49 -0
  11. package/src/components/form/Form.vue +285 -0
  12. package/src/components/layout/Actions.vue +132 -0
  13. package/src/components/layout/Drawer.vue +87 -0
  14. package/src/components/layout/Layout.vue +193 -0
  15. package/src/components/layout/SideBar.vue +57 -0
  16. package/src/components/layout/ViewTabs.vue +136 -0
  17. package/src/components/mobile/Form.vue +16 -0
  18. package/src/components/model/Create.vue +20 -0
  19. package/src/components/model/Form.vue +136 -0
  20. package/src/components/model/Relations.vue +236 -0
  21. package/src/components/model/Search.vue +103 -0
  22. package/src/components/model/Select.vue +91 -0
  23. package/src/components/model/Table.vue +335 -0
  24. package/src/components/table/Widget.vue +195 -0
  25. package/src/components/widgets/ForeignKey.vue +58 -0
  26. package/src/components/widgets/Icon.vue +37 -0
  27. package/src/composables/context.js +16 -0
  28. package/src/composables/drawer.js +19 -0
  29. package/src/composables/tab.js +7 -0
  30. package/src/configs/axios.js +2 -0
  31. package/src/core/auth.js +68 -0
  32. package/src/core/http.js +52 -0
  33. package/src/core/menus.js +39 -0
  34. package/src/core/metadata.js +50 -0
  35. package/src/core/registry.js +127 -0
  36. package/src/core/relations.js +74 -0
  37. package/src/core/table.js +48 -0
  38. package/src/core/views.js +23 -0
  39. package/src/index.js +31 -0
  40. package/src/router/index.js +78 -0
  41. package/src/style.css +23 -0
  42. package/src/utils/app_model.js +2 -0
  43. package/src/utils/array_normalize.js +1 -0
@@ -0,0 +1,285 @@
1
+ <script setup>
2
+ import { computed, nextTick, ref, useAttrs, watch } from 'vue'
3
+ import { ElForm, ElFormItem, ElRow, ElCol, ElButton, ElAlert, ElMessage } from 'element-plus'
4
+ import Field from './Field.vue'
5
+ import { normalizeItems, getItemRules } from './Form.js'
6
+ import { joinErrors } from '../../core/http.js'
7
+ import { useDjango } from '../../composables/context.js'
8
+ import Schema from 'async-validator'
9
+ import { Form as VanForm, Field as VanField, Button as VanButton, NoticeBar } from 'vant'
10
+ defineOptions({ inheritAttrs: false })
11
+ const props = defineProps({
12
+ modelValue: Object,
13
+ value: Object,
14
+ items: { type: Array, default: () => [] },
15
+ groups: { type: Array, default: () => [] },
16
+ actions: Array,
17
+ url: String,
18
+ method: { type: String, default: 'post' },
19
+ options: { type: Object, default: () => ({}) },
20
+ submit: Function,
21
+ submitName: { type: String, default: '提交' },
22
+ successInfo: String,
23
+ showSuccess: { type: Boolean, default: true },
24
+ disabled: Boolean,
25
+ noLabel: Boolean,
26
+ oneColumn: Boolean,
27
+ inline: Boolean,
28
+ itemOptions: { type: Object, default: () => ({}) },
29
+ mobile: Boolean,
30
+ })
31
+ const emit = defineEmits(['update:modelValue', 'input', 'beforesubmit', 'form-posted', 'error'])
32
+ const { registry } = useDjango()
33
+ const attrs = useAttrs(),
34
+ form = ref(),
35
+ formValue = ref({}),
36
+ errors = ref({}),
37
+ loading = ref(false)
38
+ const formItems = computed(() =>
39
+ normalizeItems(
40
+ props.items.length ? props.items : props.groups.flatMap((group) => group.items || []),
41
+ ),
42
+ )
43
+ const rules = computed(() => getItemRules(formItems.value))
44
+ watch(
45
+ () => props.modelValue ?? props.value,
46
+ (value) => {
47
+ formValue.value = { ...value }
48
+ },
49
+ { immediate: true, deep: true },
50
+ )
51
+ function publish() {
52
+ emit('update:modelValue', { ...formValue.value })
53
+ emit('input', { ...formValue.value })
54
+ }
55
+ function update(name, value) {
56
+ formValue.value[name] = value
57
+ delete errors.value[name]
58
+ publish()
59
+ }
60
+ async function onSubmit() {
61
+ if (loading.value || props.disabled) return
62
+ errors.value = {}
63
+ try {
64
+ for (const field of formItems.value) {
65
+ if (typeof field.widget === 'function')
66
+ formValue.value[field.name] = field.widget(formValue.value)
67
+ }
68
+ const Validator = Schema.default || Schema
69
+ await new Validator(rules.value).validate(formValue.value)
70
+ await nextTick()
71
+ if (!props.mobile) {
72
+ const valid = await form.value.validate()
73
+ if (!valid) return false
74
+ }
75
+ } catch (validation) {
76
+ if (validation.fields)
77
+ errors.value = Object.fromEntries(
78
+ Object.entries(validation.fields).map(([name, messages]) => [
79
+ name,
80
+ messages.map((item) => item.message).join(';'),
81
+ ]),
82
+ )
83
+ return false
84
+ }
85
+ loading.value = true
86
+ try {
87
+ emit('beforesubmit', formValue.value)
88
+ publish()
89
+ let data
90
+ if (props.submit)
91
+ data = await props.submit({
92
+ formValue: formValue.value,
93
+ formItems: formItems.value,
94
+ errors: errors.value,
95
+ loading,
96
+ url: props.url,
97
+ method: props.method,
98
+ })
99
+ else {
100
+ if (!props.url) throw new Error('表单提交需要 url 或 submit 函数')
101
+ const response = await registry.http.request({
102
+ url: props.url,
103
+ method: props.method,
104
+ data: formValue.value,
105
+ })
106
+ data = response.data
107
+ }
108
+ if (data === false) return false
109
+ if (props.showSuccess) ElMessage.success(props.successInfo || `${props.submitName}成功`)
110
+ emit('form-posted', data)
111
+ return data
112
+ } catch (error) {
113
+ const status = error.code ?? error.response?.status
114
+ errors.value =
115
+ status === 400
116
+ ? joinErrors(error.msg ?? error.response?.data ?? {})
117
+ : { non_field_errors: error.message || '请求失败' }
118
+ emit('error', error)
119
+ } finally {
120
+ loading.value = false
121
+ }
122
+ }
123
+ const context = { onSubmit, formValue, formItems, loading, errors }
124
+ defineExpose({ ...context, submit: onSubmit })
125
+ </script>
126
+
127
+ <template>
128
+ <VanForm
129
+ v-if="mobile"
130
+ :aria-busy="loading"
131
+ @submit="onSubmit"
132
+ >
133
+ <slot name="header" />
134
+ <NoticeBar
135
+ v-if="errors.non_field_errors || errors.detail"
136
+ :text="errors.non_field_errors || errors.detail"
137
+ />
138
+ <template
139
+ v-for="field in formItems"
140
+ :key="field.name"
141
+ >
142
+ <VanField
143
+ v-if="!field.hidden && field.widget !== 'hidden'"
144
+ :label="noLabel || field.noLabel ? '' : field.label"
145
+ :required="field.required"
146
+ :error-message="errors[field.name]"
147
+ >
148
+ <template #input>
149
+ <slot
150
+ :name="`field-${field.name}`"
151
+ :field="field"
152
+ :value="formValue[field.name]"
153
+ :update="(value) => update(field.name, value)"
154
+ >
155
+ <span v-if="typeof field.widget === 'function'">{{ field.widget(formValue) }}</span>
156
+ <Field
157
+ v-else
158
+ :field="field"
159
+ :model-value="formValue[field.name]"
160
+ mobile
161
+ @update:model-value="update(field.name, $event)"
162
+ />
163
+ </slot>
164
+ </template>
165
+ </VanField>
166
+ </template>
167
+ <slot
168
+ v-if="submitName"
169
+ name="submit"
170
+ :submit="onSubmit"
171
+ :loading="loading"
172
+ :saving="loading"
173
+ >
174
+ <template v-if="actions"
175
+ ><VanButton
176
+ v-for="action in actions"
177
+ :key="action.name"
178
+ :disabled="loading || disabled"
179
+ @click="action.name === 'submit' ? onSubmit() : action.do?.(context)"
180
+ >{{ action.label || action.name }}</VanButton
181
+ ></template
182
+ >
183
+ <VanButton
184
+ v-else
185
+ block
186
+ type="primary"
187
+ native-type="submit"
188
+ :loading="loading"
189
+ :disabled="disabled"
190
+ >{{ submitName }}</VanButton
191
+ >
192
+ </slot>
193
+ <slot name="footer" />
194
+ </VanForm>
195
+ <ElForm
196
+ v-else
197
+ ref="form"
198
+ v-bind="{ ...options.elForm, ...attrs }"
199
+ :model="formValue"
200
+ :rules="rules"
201
+ :inline="inline"
202
+ :label-width="noLabel ? '0px' : attrs.labelWidth || '160px'"
203
+ :aria-busy="loading"
204
+ @submit.prevent="onSubmit"
205
+ >
206
+ <slot name="header" />
207
+ <ElAlert
208
+ v-if="errors.non_field_errors || errors.detail"
209
+ :title="errors.non_field_errors || errors.detail"
210
+ type="error"
211
+ :closable="false"
212
+ />
213
+ <ElRow :gutter="20">
214
+ <template
215
+ v-for="field in formItems"
216
+ :key="field.name"
217
+ >
218
+ <input
219
+ v-if="field.widget === 'hidden' && !field.hidden"
220
+ type="hidden"
221
+ :value="formValue[field.name]"
222
+ />
223
+ <ElCol
224
+ v-else-if="!field.hidden"
225
+ v-bind="oneColumn || inline ? { span: 24 } : field.span"
226
+ >
227
+ <ElFormItem
228
+ v-bind="itemOptions"
229
+ :prop="field.name"
230
+ :label="noLabel || itemOptions.noLabel || field.noLabel ? '' : field.label"
231
+ :error="errors[field.name]"
232
+ >
233
+ <slot
234
+ :name="`field-${field.name}`"
235
+ :field="field"
236
+ :value="formValue[field.name]"
237
+ :update="(value) => update(field.name, value)"
238
+ >
239
+ <span v-if="typeof field.widget === 'function'">{{ field.widget(formValue) }}</span>
240
+ <Field
241
+ v-else
242
+ :field="field"
243
+ :model-value="formValue[field.name]"
244
+ :mobile="mobile"
245
+ @update:model-value="update(field.name, $event)"
246
+ />
247
+ </slot>
248
+ </ElFormItem>
249
+ </ElCol>
250
+ </template>
251
+ <ElCol
252
+ v-if="submitName"
253
+ :span="24"
254
+ ><ElFormItem>
255
+ <slot
256
+ name="submit"
257
+ :submit="onSubmit"
258
+ :loading="loading"
259
+ :saving="loading"
260
+ >
261
+ <template v-if="actions"
262
+ ><ElButton
263
+ v-for="action in actions"
264
+ :key="action.name"
265
+ :type="action.type"
266
+ :disabled="loading || disabled"
267
+ @click="action.name === 'submit' ? onSubmit() : action.do?.(context)"
268
+ >{{ action.label || action.name }}</ElButton
269
+ ></template
270
+ >
271
+ <ElButton
272
+ v-else
273
+ type="primary"
274
+ native-type="submit"
275
+ :loading="loading"
276
+ :disabled="disabled"
277
+ >{{ submitName }}</ElButton
278
+ >
279
+ </slot>
280
+ </ElFormItem></ElCol
281
+ >
282
+ <slot name="footer" />
283
+ </ElRow>
284
+ </ElForm>
285
+ </template>
@@ -0,0 +1,132 @@
1
+ <script setup>
2
+ import { computed, reactive } from 'vue'
3
+ import {
4
+ ElButton,
5
+ ElButtonGroup,
6
+ ElDropdown,
7
+ ElDropdownMenu,
8
+ ElDropdownItem,
9
+ ElMessageBox,
10
+ } from 'element-plus'
11
+ import Icon from '../widgets/Icon.vue'
12
+ import { useDrawer } from '../../composables/drawer.js'
13
+
14
+ const props = defineProps({
15
+ items: { type: Array, default: () => [] },
16
+ context: { type: [Object, Function], default: () => ({}) },
17
+ map: { type: Object, default: () => ({}) },
18
+ permissionFunction: Function,
19
+ size: { type: String, default: 'small' },
20
+ trigger: { type: String, default: 'hover' },
21
+ })
22
+ const emit = defineEmits(['done', 'error', 'command'])
23
+ const drawer = useDrawer()
24
+ const loadingMap = reactive({})
25
+ const context = computed(() =>
26
+ typeof props.context === 'function' ? props.context() : props.context,
27
+ )
28
+ function normalize(item) {
29
+ if (Array.isArray(item)) return item.flatMap((entry) => normalize(entry))
30
+ const name = typeof item === 'string' ? item : item.name
31
+ const action = { ...props.map[name], ...(typeof item === 'string' ? {} : item), name }
32
+ action.label = action.label ?? action.title ?? name
33
+ return action
34
+ }
35
+ function allowed(action) {
36
+ return (
37
+ (!action.permission ||
38
+ !props.permissionFunction ||
39
+ props.permissionFunction(action.permission)) &&
40
+ (!action.show || action.show(context.value))
41
+ )
42
+ }
43
+ const buttons = computed(() =>
44
+ props.items
45
+ .filter((item) => !Array.isArray(item))
46
+ .map(normalize)
47
+ .filter(allowed),
48
+ )
49
+ const dropdown = computed(() =>
50
+ props.items.filter(Array.isArray).flatMap(normalize).filter(allowed),
51
+ )
52
+
53
+ async function handleCommand(action) {
54
+ if (loadingMap[action.name] || action.disabled || !allowed(action)) return
55
+ loadingMap[action.name] = true
56
+ try {
57
+ const actionContext = context.value
58
+ if (typeof action.confirm === 'function') {
59
+ if ((await action.confirm(action, actionContext)) === false) return
60
+ } else if (action.confirm) {
61
+ await ElMessageBox.confirm(action.notice ?? `确定执行“${action.label}”吗?`, '操作确认', {
62
+ type: 'warning',
63
+ })
64
+ }
65
+ emit('command', action, actionContext)
66
+ if (typeof action.do === 'function') {
67
+ const result = await action.do(actionContext)
68
+ emit('done', result, action)
69
+ } else {
70
+ if (!drawer) throw new Error('Actions 的抽屉操作需要放在 Layout 内')
71
+ await drawer.open({
72
+ component: action.do ?? action.component,
73
+ label: action.label,
74
+ context: { ...action.drawer, ...actionContext },
75
+ onDone: async (result) => {
76
+ await action.onDone?.(result)
77
+ emit('done', result, action)
78
+ },
79
+ })
80
+ }
81
+ } catch (error) {
82
+ if (error !== 'cancel' && error !== 'close') emit('error', error, action)
83
+ } finally {
84
+ loadingMap[action.name] = false
85
+ }
86
+ }
87
+ defineExpose({ handleCommand, loadingMap })
88
+ </script>
89
+
90
+ <template>
91
+ <ElButtonGroup
92
+ v-if="buttons.length || dropdown.length"
93
+ class="vd-actions"
94
+ >
95
+ <ElButton
96
+ v-for="action in buttons"
97
+ :key="action.name"
98
+ :size="action.size ?? size"
99
+ :type="action.type"
100
+ :plain="action.plain"
101
+ :disabled="action.disabled"
102
+ :loading="loadingMap[action.name]"
103
+ @click="handleCommand(action)"
104
+ ><Icon :icon="action.icon" />{{ action.label }}</ElButton
105
+ >
106
+ <ElDropdown
107
+ v-if="dropdown.length"
108
+ :size="size"
109
+ :trigger="trigger"
110
+ @command="handleCommand"
111
+ >
112
+ <ElButton
113
+ :size="size"
114
+ aria-label="更多操作"
115
+ >更多 ▾</ElButton
116
+ >
117
+ <template #dropdown>
118
+ <ElDropdownMenu>
119
+ <ElDropdownItem
120
+ v-for="action in dropdown"
121
+ :key="action.name"
122
+ :command="action"
123
+ :disabled="action.disabled || loadingMap[action.name]"
124
+ :divided="action.divided"
125
+ >
126
+ <Icon :icon="action.icon" />{{ action.label }}
127
+ </ElDropdownItem>
128
+ </ElDropdownMenu>
129
+ </template>
130
+ </ElDropdown>
131
+ </ElButtonGroup>
132
+ </template>
@@ -0,0 +1,87 @@
1
+ <script setup>
2
+ import { ref, shallowRef } from 'vue'
3
+ import { ElDrawer, ElAlert } from 'element-plus'
4
+
5
+ const props = defineProps({ loadView: Function })
6
+ const emit = defineEmits(['done', 'error', 'closed'])
7
+ const visible = ref(false)
8
+ const current = shallowRef()
9
+ const message = ref('')
10
+ let generation = 0
11
+
12
+ async function open(options = {}) {
13
+ const request = ++generation
14
+ visible.value = false
15
+ current.value = undefined
16
+ message.value = ''
17
+ try {
18
+ let component = options.component
19
+ if (typeof component === 'string') {
20
+ if (!props.loadView) throw new Error('字符串抽屉组件需要配置 loadDrawerView')
21
+ const module = await props.loadView(component)
22
+ component = module.default ?? module
23
+ }
24
+ if (request !== generation) return
25
+ if (!component) throw new Error('抽屉缺少 component')
26
+ const context = Object.fromEntries(
27
+ Object.entries(options.context ?? {}).filter(([key]) => !key.startsWith('$')),
28
+ )
29
+ current.value = {
30
+ ...options,
31
+ component,
32
+ context,
33
+ title: options.title ?? context.title ?? options.label ?? '',
34
+ size: options.size ?? context.size ?? '30%',
35
+ }
36
+ visible.value = true
37
+ } catch (error) {
38
+ if (request !== generation) return
39
+ emit('error', error)
40
+ throw error
41
+ }
42
+ }
43
+ function close() {
44
+ generation++
45
+ visible.value = false
46
+ }
47
+ async function done(result) {
48
+ const callback = current.value?.onDone
49
+ close()
50
+ emit('done', result)
51
+ try {
52
+ await callback?.(result)
53
+ } catch (error) {
54
+ message.value = error.message
55
+ emit('error', error)
56
+ }
57
+ }
58
+ function closed() {
59
+ if (!visible.value) current.value = undefined
60
+ emit('closed')
61
+ }
62
+ defineExpose({ open, onOpen: open, close })
63
+ </script>
64
+
65
+ <template>
66
+ <ElAlert
67
+ v-if="message"
68
+ :title="message"
69
+ type="error"
70
+ />
71
+ <ElDrawer
72
+ v-model="visible"
73
+ v-bind="$attrs"
74
+ :title="current?.title"
75
+ :size="current?.size ?? '30%'"
76
+ :direction="current?.direction ?? 'rtl'"
77
+ :before-close="current?.beforeClose"
78
+ @closed="closed"
79
+ >
80
+ <component
81
+ v-if="current"
82
+ :is="current.component"
83
+ v-bind="current.context"
84
+ @done="done"
85
+ />
86
+ </ElDrawer>
87
+ </template>
@@ -0,0 +1,193 @@
1
+ <script setup>
2
+ import { computed, provide, ref } from 'vue'
3
+ import { useRoute, RouterView, RouterLink } from 'vue-router'
4
+ import { ElButton } from 'element-plus'
5
+ import SideBar from './SideBar.vue'
6
+ import ViewTabs from './ViewTabs.vue'
7
+ import Drawer from './Drawer.vue'
8
+ import Actions from './Actions.vue'
9
+ import { DrawerKey } from '../../composables/drawer.js'
10
+ const props = defineProps({
11
+ menus: Object,
12
+ title: { type: String, default: 'vue3-django' },
13
+ user: Object,
14
+ actions: Array,
15
+ actionContext: [Object, Function],
16
+ actionMap: Object,
17
+ permissionFunction: Function,
18
+ loadDrawerView: Function,
19
+ })
20
+ const emit = defineEmits(['logout', 'action-done', 'error'])
21
+ const route = useRoute(),
22
+ opened = ref(false),
23
+ tabs = ref()
24
+ const drawer = ref()
25
+ const drawerApi = {
26
+ open: (options) => drawer.value.open(options),
27
+ close: () => drawer.value.close(),
28
+ }
29
+ provide(DrawerKey, drawerApi)
30
+ const standalone = computed(() => route.meta.layout === 'main')
31
+ defineExpose({
32
+ resetTabs: () => tabs.value?.resetTabs(),
33
+ openDrawer: drawerApi.open,
34
+ closeDrawer: drawerApi.close,
35
+ })
36
+ </script>
37
+ <template>
38
+ <RouterView v-if="standalone" />
39
+ <div
40
+ v-else
41
+ class="django-layout"
42
+ >
43
+ <header class="layout-header">
44
+ <ElButton
45
+ class="menu-toggle"
46
+ aria-label="切换导航菜单"
47
+ :aria-expanded="opened"
48
+ @click="opened = !opened"
49
+ >菜单</ElButton
50
+ ><RouterLink
51
+ to="/"
52
+ class="layout-brand"
53
+ >{{ title }}</RouterLink
54
+ >
55
+ <div class="layout-account">
56
+ <slot name="actions">
57
+ <Actions
58
+ :items="actions"
59
+ :context="actionContext"
60
+ :map="actionMap"
61
+ :permission-function="permissionFunction"
62
+ @done="(result, action) => emit('action-done', result, action)"
63
+ @error="emit('error', $event)"
64
+ />
65
+ </slot>
66
+ <span>{{ user?.name || user?.username }}</span
67
+ ><ElButton
68
+ v-if="user"
69
+ @click="$emit('logout')"
70
+ >退出登录</ElButton
71
+ >
72
+ </div>
73
+ </header>
74
+ <div class="layout-body">
75
+ <button
76
+ v-if="opened"
77
+ class="menu-backdrop"
78
+ aria-label="关闭导航菜单"
79
+ @click="opened = false"
80
+ />
81
+ <aside
82
+ class="layout-sidebar"
83
+ :class="{ opened }"
84
+ >
85
+ <SideBar
86
+ :menus="menus"
87
+ @navigate="opened = false"
88
+ />
89
+ </aside>
90
+ <section
91
+ class="layout-content"
92
+ aria-label="工作区"
93
+ >
94
+ <ViewTabs ref="tabs" />
95
+ </section>
96
+ </div>
97
+ </div>
98
+ <Drawer
99
+ ref="drawer"
100
+ :load-view="loadDrawerView"
101
+ @error="emit('error', $event)"
102
+ />
103
+ </template>
104
+ <style scoped>
105
+ .django-layout {
106
+ min-height: 100vh;
107
+ background: #f4f7fa;
108
+ color: #253447;
109
+ }
110
+ .layout-header {
111
+ height: 64px;
112
+ padding: 0 24px;
113
+ display: flex;
114
+ align-items: center;
115
+ gap: 16px;
116
+ background: white;
117
+ border-bottom: 1px solid #e5eaf0;
118
+ }
119
+ .layout-brand {
120
+ font-size: 20px;
121
+ font-weight: 700;
122
+ color: inherit;
123
+ text-decoration: none;
124
+ }
125
+ .layout-account {
126
+ margin-left: auto;
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 16px;
130
+ }
131
+ .layout-body {
132
+ display: flex;
133
+ min-height: calc(100vh - 65px);
134
+ }
135
+ .layout-sidebar {
136
+ width: 220px;
137
+ flex-shrink: 0;
138
+ background: white;
139
+ border-right: 1px solid #e5eaf0;
140
+ }
141
+ .layout-sidebar :deep(.el-menu) {
142
+ border-right: 0;
143
+ }
144
+ .layout-content {
145
+ min-width: 0;
146
+ flex: 1;
147
+ padding-top: 20px;
148
+ }
149
+ .menu-toggle,
150
+ .menu-backdrop {
151
+ display: none;
152
+ }
153
+ .layout-content :deep(main) {
154
+ margin: 24px auto;
155
+ }
156
+ .layout-content :deep(.viewtabs) {
157
+ margin: 0 20px;
158
+ }
159
+ @media (max-width: 768px) {
160
+ .menu-toggle {
161
+ display: inline-flex;
162
+ }
163
+ .layout-header {
164
+ padding: 0 12px;
165
+ }
166
+ .layout-brand {
167
+ font-size: 16px;
168
+ }
169
+ .layout-sidebar {
170
+ display: none;
171
+ }
172
+ .layout-sidebar.opened {
173
+ display: block;
174
+ position: fixed;
175
+ top: 65px;
176
+ bottom: 0;
177
+ left: 0;
178
+ z-index: 21;
179
+ overflow: auto;
180
+ }
181
+ .menu-backdrop {
182
+ display: block;
183
+ position: fixed;
184
+ inset: 65px 0 0;
185
+ background: #0006;
186
+ border: 0;
187
+ z-index: 20;
188
+ }
189
+ .layout-content :deep(.viewtabs) {
190
+ margin: 0 8px;
191
+ }
192
+ }
193
+ </style>