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,57 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { ElMenu, ElMenuItem, ElSubMenu } from 'element-plus'
5
+ import Icon from '../widgets/Icon.vue'
6
+ const props = defineProps({ menus: { type: Object, default: () => ({}) }, collapse: Boolean })
7
+ const emit = defineEmits(['navigate'])
8
+ const route = useRoute(),
9
+ router = useRouter()
10
+ const groups = computed(() => Object.values(props.menus).filter((g) => g.items?.length))
11
+ const active = computed(
12
+ () =>
13
+ groups.value
14
+ .flatMap((g) => g.items)
15
+ .filter((i) => route.path.startsWith(i.url))
16
+ .sort((a, b) => b.url.length - a.url.length)[0]?.url || route.path,
17
+ )
18
+ async function select(path) {
19
+ await router.push(path)
20
+ emit('navigate')
21
+ }
22
+ </script>
23
+ <template>
24
+ <nav aria-label="主导航">
25
+ <ElMenu
26
+ :default-active="active"
27
+ :collapse="collapse"
28
+ :default-openeds="groups.map((g) => g.name)"
29
+ @select="select"
30
+ >
31
+ <template
32
+ v-for="group in groups"
33
+ :key="group.name"
34
+ >
35
+ <ElSubMenu
36
+ v-if="group.items.length > 1"
37
+ :index="group.name"
38
+ ><template #title
39
+ ><Icon :icon="group.icon" /><span>{{ group.name }}</span></template
40
+ ><ElMenuItem
41
+ v-for="item in group.items"
42
+ :key="item.url"
43
+ :index="item.url"
44
+ ><Icon :icon="item.icon" /><template #title>{{ item.name }}</template></ElMenuItem
45
+ ></ElSubMenu
46
+ >
47
+ <ElMenuItem
48
+ v-else
49
+ :index="group.items[0].url"
50
+ ><Icon :icon="group.items[0].icon || group.icon" /><template #title>{{
51
+ group.items[0].name
52
+ }}</template></ElMenuItem
53
+ >
54
+ </template>
55
+ </ElMenu>
56
+ </nav>
57
+ </template>
@@ -0,0 +1,136 @@
1
+ <script setup>
2
+ import { computed, defineComponent, h, provide, shallowReactive, shallowRef, watch } from 'vue'
3
+ import { RouterView, routeLocationKey, useRoute, useRouter } from 'vue-router'
4
+ import { ElTabs, ElTabPane } from 'element-plus'
5
+ import { TabKey } from '../../composables/tab.js'
6
+ const props = defineProps({ fallback: { type: String, default: '/' } })
7
+ const route = useRoute(),
8
+ router = useRouter(),
9
+ tabs = shallowRef([])
10
+ const standalone = computed(() => route.meta.layout === 'main' || route.meta.tabs === false)
11
+ const RouteTab = defineComponent({
12
+ props: { tab: { type: Object, required: true } },
13
+ setup(props) {
14
+ const localRoute = shallowReactive({ ...props.tab.to })
15
+ watch(
16
+ () => props.tab.to,
17
+ (value) => Object.assign(localRoute, value),
18
+ )
19
+ provide(routeLocationKey, localRoute)
20
+ provide(TabKey, {
21
+ update({ title, icon }) {
22
+ if (title != null && title !== '') props.tab.title = String(title)
23
+ if (icon !== undefined) props.tab.icon = icon
24
+ props.tab.customTitle = true
25
+ },
26
+ })
27
+ return () =>
28
+ h(
29
+ RouterView,
30
+ { route: props.tab.to },
31
+ {
32
+ default: ({ Component }) => (Component ? h(Component, { tab: props.tab }) : null),
33
+ },
34
+ )
35
+ },
36
+ })
37
+ function changeRoute(to) {
38
+ if (to.meta.layout === 'main' || to.meta.tabs === false || !to.matched.length) return
39
+ const title = to.meta.title || to.path
40
+ const tab = {
41
+ name: to.path,
42
+ title: to.params.id != null ? `${title} #${to.params.id}` : title,
43
+ icon: to.meta.icon || to.meta.model?.icon,
44
+ to,
45
+ }
46
+ const existing = tabs.value.find((t) => t.name === tab.name)
47
+ if (existing) {
48
+ if (existing.customTitle) tab.title = existing.title
49
+ Object.assign(existing, tab)
50
+ } else tabs.value = [...tabs.value, shallowReactive(tab)]
51
+ }
52
+ watch(
53
+ () => route.fullPath,
54
+ () => changeRoute({ ...route }),
55
+ { immediate: true },
56
+ )
57
+ async function tabRemove(name) {
58
+ const index = tabs.value.findIndex((t) => t.name === name)
59
+ if (index < 0) return
60
+ if (route.path === name) {
61
+ const next = tabs.value[index + 1] || tabs.value[index - 1]
62
+ // Keep the final tab when fallback resolves to the same page.
63
+ if (!next && router.resolve(props.fallback).path === name) return
64
+ const result = await router.push(next?.to.fullPath || props.fallback)
65
+ if (result || route.path === name) return
66
+ }
67
+ tabs.value = tabs.value.filter((t) => t.name !== name)
68
+ }
69
+ function clearTabs() {
70
+ tabs.value = tabs.value.filter((t) => t.name === route.path)
71
+ }
72
+ function resetTabs() {
73
+ tabs.value = []
74
+ }
75
+ function select(name) {
76
+ const tab = tabs.value.find((t) => t.name === name)
77
+ if (tab) router.push(tab.to.fullPath)
78
+ }
79
+ defineExpose({ tabs, tabRemove, clearTabs, resetTabs })
80
+ </script>
81
+ <template>
82
+ <RouterView v-if="standalone" />
83
+ <ElTabs
84
+ v-else
85
+ :model-value="route.path"
86
+ type="card"
87
+ class="viewtabs"
88
+ @tab-change="select"
89
+ @tab-remove="tabRemove"
90
+ >
91
+ <ElTabPane
92
+ v-for="tab in tabs"
93
+ :key="tab.name"
94
+ :name="tab.name"
95
+ :closable="tabs.length > 1"
96
+ >
97
+ <template #label
98
+ ><component
99
+ v-if="tab.icon && typeof tab.icon !== 'string'"
100
+ :is="tab.icon"
101
+ class="tab-icon"
102
+ aria-hidden="true"
103
+ />
104
+ <i
105
+ v-else-if="tab.icon && /^[a-zA-Z][\w -]*$/.test(tab.icon)"
106
+ :class="tab.icon.includes(' ') ? tab.icon : `fa fa-${tab.icon}`"
107
+ class="tab-icon"
108
+ aria-hidden="true"
109
+ />
110
+ <span
111
+ v-else-if="tab.icon"
112
+ class="tab-icon"
113
+ aria-hidden="true"
114
+ >{{ tab.icon }}</span
115
+ >
116
+ <span :title="tab.title">{{
117
+ tab.title.length > 19 ? `${tab.title.slice(0, 16)}…` : tab.title
118
+ }}</span></template
119
+ >
120
+ <RouteTab :tab="tab" />
121
+ </ElTabPane>
122
+ </ElTabs>
123
+ </template>
124
+ <style scoped>
125
+ .viewtabs {
126
+ margin: 0 24px;
127
+ }
128
+ .viewtabs :deep(.el-tabs__item) {
129
+ font-size: 0.8rem;
130
+ }
131
+ .tab-icon {
132
+ width: 1em;
133
+ height: 1em;
134
+ margin-right: 6px;
135
+ }
136
+ </style>
@@ -0,0 +1,16 @@
1
+ <script setup>
2
+ import ModelForm from '../model/Form.vue'
3
+ defineOptions({ inheritAttrs: false })
4
+ </script>
5
+ <template>
6
+ <ModelForm
7
+ v-bind="$attrs"
8
+ mobile
9
+ ><template
10
+ v-for="(_, name) in $slots"
11
+ #[name]="scope"
12
+ ><slot
13
+ :name="name"
14
+ v-bind="scope" /></template
15
+ ></ModelForm>
16
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup>
2
+ import ModelForm from './Form.vue'
3
+
4
+ defineProps({
5
+ appModel: { type: String, required: true },
6
+ defaults: Object,
7
+ mobile: Boolean,
8
+ })
9
+ const emit = defineEmits(['done', 'error'])
10
+ </script>
11
+
12
+ <template>
13
+ <ModelForm
14
+ :app-model="appModel"
15
+ :defaults="defaults"
16
+ :mobile="mobile"
17
+ @form-posted="emit('done', $event)"
18
+ @error="emit('error', $event)"
19
+ />
20
+ </template>
@@ -0,0 +1,136 @@
1
+ <script setup>
2
+ import { computed, nextTick, ref, watch } from 'vue'
3
+ import { ElAlert } from 'element-plus'
4
+ import { NoticeBar } from 'vant'
5
+ import Form from '../form/Form.vue'
6
+ import { useDjango } from '../../composables/context.js'
7
+ import { emptyData, normalizeItems } from '../../core/metadata.js'
8
+ import { useViewTab } from '../../composables/tab.js'
9
+
10
+ defineOptions({ inheritAttrs: false })
11
+ const props = defineProps({
12
+ appModel: { type: String, required: true },
13
+ id: [String, Number],
14
+ modelValue: Object,
15
+ items: [Array, String],
16
+ defaults: { type: Object, default: () => ({}) },
17
+ mobile: Boolean,
18
+ })
19
+ const emit = defineEmits(['update:modelValue', 'loaded', 'form-posted', 'error', 'beforesubmit'])
20
+ const { registry } = useDjango()
21
+ const tab = useViewTab()
22
+ const form = ref(),
23
+ data = ref({}),
24
+ fields = ref([]),
25
+ message = ref(''),
26
+ loading = ref(false)
27
+ const errors = computed(() => form.value?.errors ?? {})
28
+ const isEdit = computed(() => props.id != null)
29
+ let generation = 0
30
+
31
+ function updateTabTitle(object) {
32
+ if (!isEdit.value) return
33
+ const config = registry.getConfig(props.appModel)
34
+ tab?.update({ title: object.__str__ ?? object[config.title_field || 'name'], icon: config.icon })
35
+ }
36
+ function changed(value) {
37
+ data.value = value
38
+ emit('update:modelValue', { ...value })
39
+ }
40
+ watch(
41
+ () => props.modelValue,
42
+ (value) => {
43
+ if (value) data.value = { ...value }
44
+ },
45
+ { deep: true },
46
+ )
47
+ async function load() {
48
+ const current = ++generation
49
+ loading.value = true
50
+ message.value = ''
51
+ fields.value = []
52
+ data.value = {}
53
+ try {
54
+ const model = registry.get(props.appModel)
55
+ const [metadata, views, object] = await Promise.all([
56
+ model.fields(isEdit.value ? 'PATCH' : 'POST'),
57
+ model.loadViewsConfig(),
58
+ isEdit.value ? model.loadObject(props.id) : {},
59
+ ])
60
+ if (current !== generation) return
61
+ const view = (isEdit.value ? views.update : views.create) ?? views.form ?? {}
62
+ fields.value = normalizeItems(props.items ?? view.items ?? 'all', metadata).filter(
63
+ (field) => !field.read_only,
64
+ )
65
+ changed({ ...emptyData(metadata, props.defaults), ...object, ...props.modelValue })
66
+ updateTabTitle(object)
67
+ emit('loaded', model)
68
+ } catch (error) {
69
+ if (current === generation) {
70
+ message.value = error.message
71
+ emit('error', error)
72
+ }
73
+ } finally {
74
+ if (current === generation) loading.value = false
75
+ }
76
+ }
77
+ function save({ formValue }) {
78
+ return registry.get(props.appModel).save(formValue, props.id, props.defaults)
79
+ }
80
+ function posted(result) {
81
+ changed({ ...data.value, ...result })
82
+ updateTabTitle(result)
83
+ emit('form-posted', { model: registry.getConfig(props.appModel), data: result, intent: 'save' })
84
+ }
85
+ async function submit() {
86
+ if (loading.value) return
87
+ await nextTick()
88
+ return form.value?.onSubmit()
89
+ }
90
+ watch(() => [props.appModel, props.id, props.items, props.defaults], load, { immediate: true })
91
+ defineExpose({ load, submit, data, errors })
92
+ </script>
93
+
94
+ <template>
95
+ <div :aria-busy="loading || form?.loading">
96
+ <p v-if="loading">正在加载字段…</p>
97
+ <template v-else>
98
+ <NoticeBar
99
+ v-if="message && mobile"
100
+ :text="message"
101
+ />
102
+ <ElAlert
103
+ v-else-if="message"
104
+ :title="message"
105
+ type="error"
106
+ :closable="false"
107
+ />
108
+ <Form
109
+ ref="form"
110
+ v-bind="$attrs"
111
+ :model-value="data"
112
+ :items="fields"
113
+ :submit="save"
114
+ :mobile="mobile"
115
+ :disabled="Boolean(message) || !fields.length"
116
+ :show-success="false"
117
+ submit-name="保存"
118
+ label-position="top"
119
+ @update:model-value="changed"
120
+ @form-posted="posted"
121
+ @error="emit('error', $event)"
122
+ @beforesubmit="emit('beforesubmit', $event)"
123
+ >
124
+ <template
125
+ v-for="(_, name) in $slots"
126
+ #[name]="scope"
127
+ >
128
+ <slot
129
+ :name="name"
130
+ v-bind="scope || {}"
131
+ />
132
+ </template>
133
+ </Form>
134
+ </template>
135
+ </div>
136
+ </template>
@@ -0,0 +1,236 @@
1
+ <script setup>
2
+ import { ref, shallowRef, watch, markRaw, nextTick } from 'vue'
3
+ import { ElTabs, ElTabPane, ElAlert, ElButton, ElDialog, ElDrawer } from 'element-plus'
4
+ import ModelTable from './Table.vue'
5
+ import ModelSelect from './Select.vue'
6
+ import ModelForm from './Form.vue'
7
+ import { useDjango } from '../../composables/context.js'
8
+ import { resolveRelation, relationIds } from '../../core/relations.js'
9
+ const props = defineProps({
10
+ parent: { type: Object, required: true },
11
+ items: Array,
12
+ mobile: Boolean,
13
+ })
14
+ const emit = defineEmits(['edit', 'create', 'error', 'parent-updated'])
15
+ const { registry, loadRelationView, revisions } = useDjango()
16
+ const relations = shallowRef([]),
17
+ loading = ref(false),
18
+ error = ref(''),
19
+ active = ref('')
20
+ const adding = shallowRef(),
21
+ creating = shallowRef(),
22
+ selection = ref([]),
23
+ saving = ref(false)
24
+ let generation = 0
25
+ async function load() {
26
+ const current = ++generation
27
+ if ((props.parent.id ?? props.parent.data?.id) == null) {
28
+ relations.value = []
29
+ return
30
+ }
31
+ loading.value = true
32
+ error.value = ''
33
+ try {
34
+ const config =
35
+ props.parent.viewsConfig ?? (await registry.get(props.parent.appModel).loadViewsConfig())
36
+ const results = await Promise.all(
37
+ (props.items ?? config.relations ?? []).map(async (definition, index) => {
38
+ const key =
39
+ typeof definition === 'object'
40
+ ? (definition.key ?? `${definition.name}:${index}`)
41
+ : `${definition}:${index}`
42
+ try {
43
+ const item = await resolveRelation(definition, props.parent, registry)
44
+ if (typeof item.view === 'string') {
45
+ if (!loadRelationView) throw new Error(`自定义视图 ${item.view} 需要 loadRelationView`)
46
+ const module = await loadRelationView(item.name, item.view)
47
+ item.view = markRaw(module.default ?? module)
48
+ } else if (item.view) item.view = markRaw(item.view)
49
+ return { ...item, key }
50
+ } catch (e) {
51
+ return {
52
+ key,
53
+ label:
54
+ typeof definition === 'string' ? definition : definition.label || definition.name,
55
+ error: e.message,
56
+ }
57
+ }
58
+ }),
59
+ )
60
+ if (current !== generation) return
61
+ relations.value = results
62
+ if (!results.some((item) => item.key === active.value)) active.value = results[0]?.key || ''
63
+ } catch (e) {
64
+ if (current === generation) {
65
+ error.value = e.message
66
+ emit('error', e)
67
+ }
68
+ } finally {
69
+ if (current === generation) loading.value = false
70
+ }
71
+ }
72
+ async function updateMembership(item, ids) {
73
+ if (saving.value) return
74
+ saving.value = true
75
+ error.value = ''
76
+ try {
77
+ const model = registry.get(props.parent.appModel)
78
+ const id = props.parent.id ?? props.parent.data[model.config.idField || 'id']
79
+ const { data } = await registry.http.patch(model.getDetailUrl(id), {
80
+ [item.multipleField.name]: ids,
81
+ })
82
+ emit('parent-updated', {
83
+ ...props.parent.data,
84
+ [item.multipleField.name]: data[item.multipleField.name] ?? ids,
85
+ })
86
+ adding.value = undefined
87
+ creating.value = undefined
88
+ await nextTick()
89
+ await load()
90
+ } catch (e) {
91
+ error.value = e.message
92
+ emit('error', e)
93
+ } finally {
94
+ saving.value = false
95
+ }
96
+ }
97
+ function beginAdd(item) {
98
+ selection.value = []
99
+ adding.value = item
100
+ }
101
+ function addSelected() {
102
+ const item = adding.value
103
+ return updateMembership(item, [
104
+ ...new Set([...relationIds(props.parent.data[item.multipleField.name]), ...selection.value]),
105
+ ])
106
+ }
107
+ function remove(item, row) {
108
+ const id = row[item.model.config.idField || 'id']
109
+ return updateMembership(
110
+ item,
111
+ relationIds(props.parent.data[item.multipleField.name]).filter(
112
+ (value) => String(value) !== String(id),
113
+ ),
114
+ )
115
+ }
116
+ async function saved(item, event) {
117
+ if (item.multipleField)
118
+ await updateMembership(item, [
119
+ ...new Set([
120
+ ...relationIds(props.parent.data[item.multipleField.name]),
121
+ event.data[item.model.config.idField || 'id'],
122
+ ]),
123
+ ])
124
+ else {
125
+ creating.value = undefined
126
+ if (revisions) revisions[item.name] = (revisions[item.name] || 0) + 1
127
+ await load()
128
+ }
129
+ }
130
+ watch(() => [props.parent, props.items], load, { immediate: true, deep: true })
131
+ watch(() => relations.value.map((item) => revisions?.[item.name] ?? 0).join(','), load)
132
+ defineExpose({ refresh: load, relations })
133
+ </script>
134
+ <template>
135
+ <section :aria-busy="loading">
136
+ <p v-if="loading && !relations.length">加载关联模型…</p>
137
+ <ElAlert
138
+ v-if="error"
139
+ :title="error"
140
+ type="error"
141
+ :closable="false"
142
+ />
143
+ <ElTabs
144
+ v-if="relations.length"
145
+ v-model="active"
146
+ type="border-card"
147
+ >
148
+ <ElTabPane
149
+ v-for="item in relations"
150
+ :key="item.key"
151
+ :name="item.key"
152
+ :label="item.label"
153
+ lazy
154
+ >
155
+ <ElAlert
156
+ v-if="item.error"
157
+ :title="item.error"
158
+ type="error"
159
+ :closable="false"
160
+ />
161
+ <component
162
+ v-else-if="item.view"
163
+ :is="item.view"
164
+ v-bind="item"
165
+ :parent="parent"
166
+ :app-model="item.name"
167
+ :mobile="mobile"
168
+ />
169
+ <template v-else>
170
+ <ElButton
171
+ v-if="item.multipleField"
172
+ :disabled="saving"
173
+ @click="beginAdd(item)"
174
+ >添加已有{{ item.label }}</ElButton
175
+ >
176
+ <ModelTable
177
+ :key="`${item.key}:${generation}`"
178
+ :app-model="item.name"
179
+ :items="item.items"
180
+ :base-queries="item.baseQueries"
181
+ :mobile="mobile"
182
+ create-mode="event"
183
+ :row-actions="
184
+ item.multipleField
185
+ ? [
186
+ {
187
+ name: 'remove-relation',
188
+ label: '移出关联',
189
+ do: ({ row }) => remove(item, row),
190
+ },
191
+ ]
192
+ : undefined
193
+ "
194
+ @edit="emit('edit', { appModel: item.name, row: $event })"
195
+ @create="creating = item"
196
+ />
197
+ </template>
198
+ </ElTabPane>
199
+ </ElTabs>
200
+ <ElDialog
201
+ :model-value="Boolean(adding)"
202
+ title="添加已有记录"
203
+ @close="adding = undefined"
204
+ >
205
+ <ModelSelect
206
+ v-if="adding"
207
+ v-model="selection"
208
+ :app-model="adding.name"
209
+ :field="{ multiple: true, label: adding.label }"
210
+ />
211
+ <template #footer
212
+ ><ElButton
213
+ :loading="saving"
214
+ :disabled="!selection.length"
215
+ @click="addSelected"
216
+ >添加关联</ElButton
217
+ ></template
218
+ >
219
+ </ElDialog>
220
+ <ElDrawer
221
+ :model-value="Boolean(creating)"
222
+ :title="`新增${creating?.label || ''}`"
223
+ :size="mobile ? '100%' : '66%'"
224
+ destroy-on-close
225
+ @close="creating = undefined"
226
+ >
227
+ <ModelForm
228
+ v-if="creating"
229
+ :app-model="creating.name"
230
+ :defaults="creating.defaults"
231
+ :mobile="mobile"
232
+ @form-posted="saved(creating, $event)"
233
+ />
234
+ </ElDrawer>
235
+ </section>
236
+ </template>