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.
- package/README.md +92 -0
- package/dist/vue3-django.css +1 -0
- package/dist/vue3-django.js +4439 -0
- package/docs/iteration-01-preview.jpg +0 -0
- package/docs/iterations.md +37 -0
- package/docs/migration.md +133 -0
- package/docs/verification.md +25 -0
- package/package.json +53 -0
- package/src/components/form/Field.vue +113 -0
- package/src/components/form/Form.js +49 -0
- package/src/components/form/Form.vue +285 -0
- package/src/components/layout/Actions.vue +132 -0
- package/src/components/layout/Drawer.vue +87 -0
- package/src/components/layout/Layout.vue +193 -0
- package/src/components/layout/SideBar.vue +57 -0
- package/src/components/layout/ViewTabs.vue +136 -0
- package/src/components/mobile/Form.vue +16 -0
- package/src/components/model/Create.vue +20 -0
- package/src/components/model/Form.vue +136 -0
- package/src/components/model/Relations.vue +236 -0
- package/src/components/model/Search.vue +103 -0
- package/src/components/model/Select.vue +91 -0
- package/src/components/model/Table.vue +335 -0
- package/src/components/table/Widget.vue +195 -0
- package/src/components/widgets/ForeignKey.vue +58 -0
- package/src/components/widgets/Icon.vue +37 -0
- package/src/composables/context.js +16 -0
- package/src/composables/drawer.js +19 -0
- package/src/composables/tab.js +7 -0
- package/src/configs/axios.js +2 -0
- package/src/core/auth.js +68 -0
- package/src/core/http.js +52 -0
- package/src/core/menus.js +39 -0
- package/src/core/metadata.js +50 -0
- package/src/core/registry.js +127 -0
- package/src/core/relations.js +74 -0
- package/src/core/table.js +48 -0
- package/src/core/views.js +23 -0
- package/src/index.js +31 -0
- package/src/router/index.js +78 -0
- package/src/style.css +23 -0
- package/src/utils/app_model.js +2 -0
- package/src/utils/array_normalize.js +1 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, toRaw } from 'vue'
|
|
3
|
+
import DOMPurify from 'dompurify'
|
|
4
|
+
import { ElImage, ElAvatar, ElTag } from 'element-plus'
|
|
5
|
+
import ForeignKey from '../widgets/ForeignKey.vue'
|
|
6
|
+
import FormField from '../form/Field.vue'
|
|
7
|
+
import { fieldValue, safeUrl, tableDate, tableDisplay } from '../../core/table.js'
|
|
8
|
+
|
|
9
|
+
const props = defineProps({
|
|
10
|
+
value: { type: Object, required: true },
|
|
11
|
+
field: { type: Object, required: true },
|
|
12
|
+
context: { type: Object, default: () => ({}) },
|
|
13
|
+
mobile: Boolean,
|
|
14
|
+
})
|
|
15
|
+
const emit = defineEmits(['change'])
|
|
16
|
+
const raw = computed(() => fieldValue(props.value, props.field.name))
|
|
17
|
+
const display = computed(() => tableDisplay(props.field, props.value))
|
|
18
|
+
const widget = computed(() => toRaw(props.field.cellWidget ?? props.field.widget))
|
|
19
|
+
const kind = computed(() => (typeof widget.value === 'string' ? widget.value.toLowerCase() : ''))
|
|
20
|
+
const context = computed(() => ({ row: props.value, ...props.context }))
|
|
21
|
+
const html = computed(() =>
|
|
22
|
+
DOMPurify.sanitize(
|
|
23
|
+
String(
|
|
24
|
+
typeof widget.value === 'function'
|
|
25
|
+
? widget.value(props.value, props.field)
|
|
26
|
+
: (display.value ?? ''),
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
const date = computed(() => tableDate(raw.value, kind.value === 'timestamp'))
|
|
31
|
+
const images = computed(() =>
|
|
32
|
+
(Array.isArray(raw.value) ? raw.value : [raw.value])
|
|
33
|
+
.map((value) =>
|
|
34
|
+
safeUrl(`${props.field.proxy ?? ''}${props.field.imageRoot ?? ''}${value ?? ''}`),
|
|
35
|
+
)
|
|
36
|
+
.filter(Boolean),
|
|
37
|
+
)
|
|
38
|
+
const jsonItems = computed(() =>
|
|
39
|
+
(props.field.items ?? Object.keys(raw.value ?? {})).map((item) =>
|
|
40
|
+
typeof item === 'string' ? { name: item } : item,
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<FormField
|
|
47
|
+
v-if="field.useFormWidget"
|
|
48
|
+
:field="{ ...field, read_only: false }"
|
|
49
|
+
:model-value="raw"
|
|
50
|
+
:mobile="mobile"
|
|
51
|
+
@update:model-value="emit('change', $event)"
|
|
52
|
+
/>
|
|
53
|
+
<component
|
|
54
|
+
:is="widget"
|
|
55
|
+
v-else-if="widget && typeof widget === 'object'"
|
|
56
|
+
:value="value"
|
|
57
|
+
:model-value="value"
|
|
58
|
+
:field="field"
|
|
59
|
+
:context="context"
|
|
60
|
+
@update:model-value="emit('change', $event)"
|
|
61
|
+
/>
|
|
62
|
+
<span
|
|
63
|
+
v-else-if="typeof widget === 'function' || kind === 'html'"
|
|
64
|
+
v-html="html"
|
|
65
|
+
/>
|
|
66
|
+
<ForeignKey
|
|
67
|
+
v-else-if="
|
|
68
|
+
kind === 'foreignkey' || (!widget && !field.formatter && (field.model || field.relateModel))
|
|
69
|
+
"
|
|
70
|
+
:value="value"
|
|
71
|
+
:field="field"
|
|
72
|
+
:context="context"
|
|
73
|
+
/>
|
|
74
|
+
<span v-else-if="raw == null || raw === ''">{{ display }}</span>
|
|
75
|
+
<span
|
|
76
|
+
v-else-if="kind === 'trueflag' || (!widget && !field.formatter && field.type === 'boolean')"
|
|
77
|
+
:class="['vd-table-boolean', { 'is-true': raw }]"
|
|
78
|
+
:aria-label="raw ? '是' : '否'"
|
|
79
|
+
:title="raw ? '是' : '否'"
|
|
80
|
+
>{{ raw ? '✓' : '—' }}</span
|
|
81
|
+
>
|
|
82
|
+
<span
|
|
83
|
+
v-else-if="
|
|
84
|
+
['date2now', 'timestamp', 'date', 'datetime'].includes(kind) ||
|
|
85
|
+
(!widget && !field.formatter && ['date', 'datetime'].includes(field.type))
|
|
86
|
+
"
|
|
87
|
+
:title="date"
|
|
88
|
+
>{{ date }}</span
|
|
89
|
+
>
|
|
90
|
+
<ElAvatar
|
|
91
|
+
v-else-if="kind === 'avatar'"
|
|
92
|
+
:src="images[0]"
|
|
93
|
+
:size="field.size ?? 40"
|
|
94
|
+
/>
|
|
95
|
+
<div
|
|
96
|
+
v-else-if="['picture', 'image', 'picturegallery'].includes(kind)"
|
|
97
|
+
class="vd-table-images"
|
|
98
|
+
>
|
|
99
|
+
<ElImage
|
|
100
|
+
v-for="url in images"
|
|
101
|
+
:key="url"
|
|
102
|
+
:src="url"
|
|
103
|
+
:preview-src-list="images"
|
|
104
|
+
preview-teleported
|
|
105
|
+
fit="contain"
|
|
106
|
+
:style="{ width: `${field.width ?? 64}px`, height: `${field.height ?? 48}px` }"
|
|
107
|
+
>
|
|
108
|
+
<template #error>图片无法加载</template>
|
|
109
|
+
</ElImage>
|
|
110
|
+
</div>
|
|
111
|
+
<video
|
|
112
|
+
v-else-if="kind === 'video'"
|
|
113
|
+
:src="safeUrl(raw)"
|
|
114
|
+
controls
|
|
115
|
+
preload="none"
|
|
116
|
+
class="vd-table-video"
|
|
117
|
+
/>
|
|
118
|
+
<a
|
|
119
|
+
v-else-if="
|
|
120
|
+
['url', 'link', 'mobile', 'email'].includes(kind) ||
|
|
121
|
+
(!widget && ['url', 'email'].includes(field.type))
|
|
122
|
+
"
|
|
123
|
+
:href="
|
|
124
|
+
safeUrl(
|
|
125
|
+
kind === 'mobile'
|
|
126
|
+
? `tel:${raw}`
|
|
127
|
+
: kind === 'email' || field.type === 'email'
|
|
128
|
+
? `mailto:${raw}`
|
|
129
|
+
: raw,
|
|
130
|
+
)
|
|
131
|
+
"
|
|
132
|
+
target="_blank"
|
|
133
|
+
rel="noopener noreferrer"
|
|
134
|
+
>{{ display }}</a
|
|
135
|
+
>
|
|
136
|
+
<dl
|
|
137
|
+
v-else-if="kind === 'jsondisplay' && typeof raw === 'object'"
|
|
138
|
+
class="vd-table-json"
|
|
139
|
+
>
|
|
140
|
+
<template
|
|
141
|
+
v-for="item in jsonItems"
|
|
142
|
+
:key="item.name"
|
|
143
|
+
>
|
|
144
|
+
<dt>{{ item.label ?? item.name }}</dt>
|
|
145
|
+
<dd>
|
|
146
|
+
<Widget
|
|
147
|
+
:value="raw"
|
|
148
|
+
:field="item"
|
|
149
|
+
:context="context"
|
|
150
|
+
:mobile="mobile"
|
|
151
|
+
/>
|
|
152
|
+
</dd>
|
|
153
|
+
</template>
|
|
154
|
+
</dl>
|
|
155
|
+
<ElTag
|
|
156
|
+
v-else-if="kind === 'colortext'"
|
|
157
|
+
:style="{ backgroundColor: field.getColor?.(context) ?? field.color }"
|
|
158
|
+
>{{ display }}</ElTag
|
|
159
|
+
>
|
|
160
|
+
<span
|
|
161
|
+
v-else
|
|
162
|
+
class="vd-table-text"
|
|
163
|
+
:title="kind === 'tooltipcell' ? String(display) : undefined"
|
|
164
|
+
>{{ display }}</span
|
|
165
|
+
>
|
|
166
|
+
</template>
|
|
167
|
+
|
|
168
|
+
<style scoped>
|
|
169
|
+
.vd-table-text {
|
|
170
|
+
white-space: pre-wrap;
|
|
171
|
+
overflow-wrap: anywhere;
|
|
172
|
+
}
|
|
173
|
+
.vd-table-boolean.is-true {
|
|
174
|
+
color: var(--el-color-success, green);
|
|
175
|
+
font-weight: bold;
|
|
176
|
+
}
|
|
177
|
+
.vd-table-images {
|
|
178
|
+
display: flex;
|
|
179
|
+
flex-wrap: wrap;
|
|
180
|
+
gap: 4px;
|
|
181
|
+
}
|
|
182
|
+
.vd-table-video {
|
|
183
|
+
width: 180px;
|
|
184
|
+
max-width: 100%;
|
|
185
|
+
}
|
|
186
|
+
.vd-table-json {
|
|
187
|
+
display: grid;
|
|
188
|
+
grid-template-columns: auto 1fr;
|
|
189
|
+
gap: 4px 12px;
|
|
190
|
+
margin: 0;
|
|
191
|
+
}
|
|
192
|
+
.vd-table-json dd {
|
|
193
|
+
margin: 0;
|
|
194
|
+
}
|
|
195
|
+
</style>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, inject } from 'vue'
|
|
3
|
+
import { RouterLink, routerKey } from 'vue-router'
|
|
4
|
+
import { useDjango } from '../../composables/context.js'
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
value: { type: Object, required: true },
|
|
8
|
+
field: { type: Object, required: true },
|
|
9
|
+
context: Object,
|
|
10
|
+
})
|
|
11
|
+
const { registry } = useDjango()
|
|
12
|
+
const router = inject(routerKey, null)
|
|
13
|
+
const modelName = computed(() => props.field.model || props.field.relateModel)
|
|
14
|
+
const primaryKey = computed(() => props.value[props.field.keyField || props.field.name])
|
|
15
|
+
const label = computed(() => {
|
|
16
|
+
const raw = props.value[`${props.field.name}_name`] ?? props.value[props.field.name]
|
|
17
|
+
const text = typeof props.field.labelFormat === 'function' ? props.field.labelFormat(raw) : raw
|
|
18
|
+
if (text == null || text === '') return '—'
|
|
19
|
+
return Array.isArray(text) ? text.join('、') : String(text)
|
|
20
|
+
})
|
|
21
|
+
const target = computed(() => {
|
|
22
|
+
if (!router || !registry.configs[modelName.value] || props.field.showLink === false) return null
|
|
23
|
+
const id = primaryKey.value
|
|
24
|
+
if (id == null || id === '' || Array.isArray(id) || typeof id === 'object') return null
|
|
25
|
+
const name = `${modelName.value.replace('.', '-')}-edit`
|
|
26
|
+
if (router.hasRoute(name)) return { name, params: { id } }
|
|
27
|
+
const path = `/${modelName.value.replace('.', '/')}/${encodeURIComponent(id)}/`
|
|
28
|
+
const resolved = router.resolve(path)
|
|
29
|
+
if (
|
|
30
|
+
!resolved.matched.length ||
|
|
31
|
+
resolved.matched.some((record) => record.path.includes(':pathMatch'))
|
|
32
|
+
)
|
|
33
|
+
return null
|
|
34
|
+
return { path }
|
|
35
|
+
})
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<RouterLink
|
|
40
|
+
v-if="target"
|
|
41
|
+
:to="target"
|
|
42
|
+
title="点击跳转"
|
|
43
|
+
class="foreignkey-link"
|
|
44
|
+
>
|
|
45
|
+
{{ label }}
|
|
46
|
+
</RouterLink>
|
|
47
|
+
<span v-else>{{ label }}</span>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<style scoped>
|
|
51
|
+
.foreignkey-link {
|
|
52
|
+
color: var(--el-color-primary, #409eff);
|
|
53
|
+
text-decoration: none;
|
|
54
|
+
}
|
|
55
|
+
.foreignkey-link:hover {
|
|
56
|
+
text-decoration: underline;
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({ icon: [String, Object, Function] })
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<component
|
|
7
|
+
v-if="icon && typeof icon !== 'string'"
|
|
8
|
+
:is="icon"
|
|
9
|
+
class="model-icon"
|
|
10
|
+
aria-hidden="true"
|
|
11
|
+
/>
|
|
12
|
+
<i
|
|
13
|
+
v-else-if="icon && /^[a-zA-Z][\w -]*$/.test(icon)"
|
|
14
|
+
:class="icon.includes(' ') ? icon : `fa fa-${icon}`"
|
|
15
|
+
class="model-icon"
|
|
16
|
+
aria-hidden="true"
|
|
17
|
+
/>
|
|
18
|
+
<span
|
|
19
|
+
v-else-if="icon"
|
|
20
|
+
class="model-icon"
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
>{{ icon }}</span
|
|
23
|
+
>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<style scoped>
|
|
27
|
+
.model-icon {
|
|
28
|
+
display: inline-flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
flex-shrink: 0;
|
|
32
|
+
width: 1.25em;
|
|
33
|
+
height: 1.25em;
|
|
34
|
+
margin-right: 8px;
|
|
35
|
+
vertical-align: middle;
|
|
36
|
+
}
|
|
37
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { inject } from 'vue'
|
|
2
|
+
import { Register } from '../core/registry.js'
|
|
3
|
+
export const DjangoKey = Symbol('vue3-django')
|
|
4
|
+
export function useDjango() {
|
|
5
|
+
return inject(DjangoKey, { registry: Register })
|
|
6
|
+
}
|
|
7
|
+
export function createDjango({ registry, ...options }) {
|
|
8
|
+
return {
|
|
9
|
+
install(app) {
|
|
10
|
+
const context = { registry, ...options }
|
|
11
|
+
app.provide(DjangoKey, context)
|
|
12
|
+
app.config.globalProperties.$http = registry.http
|
|
13
|
+
app.config.globalProperties.$django = context
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { inject } from 'vue'
|
|
2
|
+
|
|
3
|
+
export const DrawerKey = Symbol('vue3-django-drawer')
|
|
4
|
+
|
|
5
|
+
export function useDrawer() {
|
|
6
|
+
return inject(DrawerKey, undefined)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// The host supplies an explicit Vite glob; no arbitrary dynamic import paths.
|
|
10
|
+
export function createDrawerViewLoader(modules) {
|
|
11
|
+
return async (path) => {
|
|
12
|
+
if (!/^[\w-]+(?:\/[\w-]+)*$/.test(path)) throw new Error('抽屉视图路径无效')
|
|
13
|
+
const matches = Object.entries(modules).filter(([key]) => key.endsWith(`/views/${path}.vue`))
|
|
14
|
+
if (matches.length !== 1) throw new Error(`抽屉视图 ${path} 未找到或重复`)
|
|
15
|
+
const entry = matches[0][1]
|
|
16
|
+
const module = typeof entry === 'function' ? await entry() : entry
|
|
17
|
+
return module.default ?? module
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/core/auth.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { reactive } from 'vue'
|
|
2
|
+
|
|
3
|
+
// Preserve the legacy access_token cookie and auth/user endpoints.
|
|
4
|
+
export function createAuth({ http, endpoints = {}, cookieName = 'access_token' }) {
|
|
5
|
+
const urls = {
|
|
6
|
+
login: 'auth/user/login/',
|
|
7
|
+
current: 'auth/user/current/',
|
|
8
|
+
logout: 'auth/user/logout/',
|
|
9
|
+
...endpoints,
|
|
10
|
+
}
|
|
11
|
+
const state = reactive({ user: null, ready: false })
|
|
12
|
+
let pending
|
|
13
|
+
function getToken() {
|
|
14
|
+
if (typeof document === 'undefined') return undefined
|
|
15
|
+
const value = document.cookie.split('; ').find((v) => v.startsWith(`${cookieName}=`))
|
|
16
|
+
return value ? decodeURIComponent(value.slice(cookieName.length + 1)) : undefined
|
|
17
|
+
}
|
|
18
|
+
function setToken(token) {
|
|
19
|
+
if (token) http.defaults.headers.common.Authorization = `Bearer ${token}`
|
|
20
|
+
else delete http.defaults.headers.common.Authorization
|
|
21
|
+
if (typeof document !== 'undefined')
|
|
22
|
+
document.cookie = `${cookieName}=${encodeURIComponent(token || '')}; Path=/; SameSite=Lax${token ? '' : '; Max-Age=0'}${location.protocol === 'https:' ? '; Secure' : ''}`
|
|
23
|
+
}
|
|
24
|
+
const token = getToken()
|
|
25
|
+
if (token) http.defaults.headers.common.Authorization = `Bearer ${token}`
|
|
26
|
+
async function getUserInfo() {
|
|
27
|
+
if (!pending)
|
|
28
|
+
pending = http
|
|
29
|
+
.get(urls.current)
|
|
30
|
+
.then(({ data }) => {
|
|
31
|
+
state.user = data
|
|
32
|
+
state.ready = true
|
|
33
|
+
return data
|
|
34
|
+
})
|
|
35
|
+
.finally(() => {
|
|
36
|
+
pending = undefined
|
|
37
|
+
})
|
|
38
|
+
return pending
|
|
39
|
+
}
|
|
40
|
+
async function login(username, password) {
|
|
41
|
+
const { data } = await http.post(urls.login, { username, password })
|
|
42
|
+
if (data.token?.access) setToken(data.token.access)
|
|
43
|
+
try {
|
|
44
|
+
await getUserInfo()
|
|
45
|
+
} catch (error) {
|
|
46
|
+
setToken(null)
|
|
47
|
+
state.user = null
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
50
|
+
return data
|
|
51
|
+
}
|
|
52
|
+
async function logout() {
|
|
53
|
+
const { data } = await http.get(urls.logout)
|
|
54
|
+
setToken(null)
|
|
55
|
+
state.user = null
|
|
56
|
+
state.ready = false
|
|
57
|
+
return data
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
state,
|
|
61
|
+
getToken,
|
|
62
|
+
setToken,
|
|
63
|
+
removeToken: () => setToken(null),
|
|
64
|
+
getUserInfo,
|
|
65
|
+
login,
|
|
66
|
+
logout,
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/core/http.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
import qs from 'qs'
|
|
3
|
+
|
|
4
|
+
export function joinErrors(errors = {}) {
|
|
5
|
+
if (typeof errors === 'string') return { non_field_errors: errors }
|
|
6
|
+
if (Array.isArray(errors)) return { non_field_errors: errors.join(';') }
|
|
7
|
+
return Object.fromEntries(
|
|
8
|
+
Object.entries(errors).map(([name, value]) => [
|
|
9
|
+
name,
|
|
10
|
+
Array.isArray(value)
|
|
11
|
+
? value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : String(v))).join(';')
|
|
12
|
+
: typeof value === 'object' && value !== null
|
|
13
|
+
? JSON.stringify(value)
|
|
14
|
+
: String(value),
|
|
15
|
+
]),
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DrfError extends Error {
|
|
20
|
+
constructor(error) {
|
|
21
|
+
const data = error.response?.data ?? error.msg ?? error.message
|
|
22
|
+
super(typeof data === 'string' ? data : (data?.detail ?? '请求失败'))
|
|
23
|
+
this.name = 'DrfError'
|
|
24
|
+
this.code = error.response?.status ?? error.code ?? -1
|
|
25
|
+
this.msg = data
|
|
26
|
+
this.fields = this.code === 400 ? joinErrors(data) : {}
|
|
27
|
+
this.cause = error
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createHttp(options = {}) {
|
|
32
|
+
const http = axios.create({
|
|
33
|
+
baseURL: '/api/',
|
|
34
|
+
xsrfCookieName: 'csrftoken',
|
|
35
|
+
xsrfHeaderName: 'X-CSRFToken',
|
|
36
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
37
|
+
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'comma', skipNulls: true }),
|
|
38
|
+
...options,
|
|
39
|
+
})
|
|
40
|
+
http.interceptors.response.use(
|
|
41
|
+
(response) => response,
|
|
42
|
+
(error) => Promise.reject(error instanceof DrfError ? error : new DrfError(error)),
|
|
43
|
+
)
|
|
44
|
+
http.setBaseURL = (url) => {
|
|
45
|
+
http.defaults.baseURL = url
|
|
46
|
+
}
|
|
47
|
+
http.setAuthToken = (token) => {
|
|
48
|
+
if (token) http.defaults.headers.common.Authorization = `Token ${token}`
|
|
49
|
+
else delete http.defaults.headers.common.Authorization
|
|
50
|
+
}
|
|
51
|
+
return http
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const hidden = (value) => value === true || value === 'true'
|
|
2
|
+
|
|
3
|
+
// Keep the original app selection, model.menu grouping and permission-key convention.
|
|
4
|
+
export function genMenusFromApps(apps, menus, modelPermissions) {
|
|
5
|
+
const selected = Array.isArray(menus) ? menus : menus ? Object.keys(menus) : Object.keys(apps)
|
|
6
|
+
const groups = {}
|
|
7
|
+
for (const key of selected) {
|
|
8
|
+
const app = apps[key]
|
|
9
|
+
if (!app || hidden(app.hidden)) continue
|
|
10
|
+
const selection = !Array.isArray(menus) && menus?.[key]
|
|
11
|
+
const names =
|
|
12
|
+
typeof selection === 'string'
|
|
13
|
+
? selection.split(',').map((s) => s.trim())
|
|
14
|
+
: selection || Object.keys(app.models ?? {})
|
|
15
|
+
const groupName = app.verbose_name || key
|
|
16
|
+
const items = []
|
|
17
|
+
if (app.home_page && !hidden(app.home_page.hidden))
|
|
18
|
+
items.push({ url: `/${key}/index/`, ...app.home_page })
|
|
19
|
+
for (const name of names) {
|
|
20
|
+
const model = app.models?.[name]
|
|
21
|
+
if (!model) continue
|
|
22
|
+
const isHidden =
|
|
23
|
+
model.hidden !== undefined
|
|
24
|
+
? hidden(model.hidden)
|
|
25
|
+
: modelPermissions != null && !Object.hasOwn(modelPermissions, `${key}.${name}`)
|
|
26
|
+
if (isHidden) continue
|
|
27
|
+
const item = { name: model.verbose_name || name, icon: model.icon, url: `/${key}/${name}/` }
|
|
28
|
+
if (model.menu) {
|
|
29
|
+
groups[model.menu] ??= { name: model.menu, items: [] }
|
|
30
|
+
groups[model.menu].items.push(item)
|
|
31
|
+
} else items.push(item)
|
|
32
|
+
}
|
|
33
|
+
if (items.length) {
|
|
34
|
+
groups[groupName] ??= { name: groupName, icon: app.icon, items: [] }
|
|
35
|
+
groups[groupName].items.push(...items)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return groups
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function normalizeItems(items = 'all', templates = {}, normalize = (value) => value) {
|
|
2
|
+
const names = items === 'all' ? Object.keys(templates) : items
|
|
3
|
+
return names.map((item) => {
|
|
4
|
+
const name = typeof item === 'string' ? item : item.name
|
|
5
|
+
return normalize({ ...templates[name], ...(typeof item === 'string' ? {} : item), name })
|
|
6
|
+
})
|
|
7
|
+
}
|
|
8
|
+
export function fieldsFromOptions(options = {}, method = 'POST') {
|
|
9
|
+
const actions = options.actions ?? {}
|
|
10
|
+
return Object.fromEntries(
|
|
11
|
+
Object.entries({ ...actions.LIST, ...(actions[method] ?? actions.POST) }).map(
|
|
12
|
+
([name, field]) => [name, { ...field, name }],
|
|
13
|
+
),
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
export function emptyData(fields, defaults = {}) {
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
Object.entries(fields)
|
|
19
|
+
.filter(([, f]) => !f.read_only)
|
|
20
|
+
.map(([name, f]) => {
|
|
21
|
+
const value = Object.hasOwn(defaults, name)
|
|
22
|
+
? defaults[name]
|
|
23
|
+
: f.default !== undefined
|
|
24
|
+
? f.default
|
|
25
|
+
: f.multiple
|
|
26
|
+
? []
|
|
27
|
+
: f.type === 'boolean'
|
|
28
|
+
? true
|
|
29
|
+
: f.type === 'string'
|
|
30
|
+
? ''
|
|
31
|
+
: f.type === 'nested object'
|
|
32
|
+
? {}
|
|
33
|
+
: null
|
|
34
|
+
return [name, structuredClone(value)]
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
export function writableData(data, fields) {
|
|
39
|
+
return Object.fromEntries(
|
|
40
|
+
Object.entries(data).filter(([name]) => fields[name] && !fields[name].read_only),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
export function displayValue(field, value) {
|
|
44
|
+
const one = (value) => field.choices?.find((c) => c.value === value)?.display_name ?? value
|
|
45
|
+
if (Array.isArray(value)) return value.map(one).join('、')
|
|
46
|
+
if (value == null) return '—'
|
|
47
|
+
if (field.choices) return one(value)
|
|
48
|
+
if (typeof value === 'boolean') return value ? '是' : '否'
|
|
49
|
+
return typeof value === 'object' ? JSON.stringify(value) : value
|
|
50
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHttp } from './http.js'
|
|
2
|
+
import { fieldsFromOptions, emptyData, writableData } from './metadata.js'
|
|
3
|
+
|
|
4
|
+
export function createRegistry({
|
|
5
|
+
http = createHttp(),
|
|
6
|
+
apps = {},
|
|
7
|
+
views = {},
|
|
8
|
+
loadViewsConfig,
|
|
9
|
+
} = {}) {
|
|
10
|
+
const configs = Object.create(null)
|
|
11
|
+
const models = new Map()
|
|
12
|
+
const registry = {
|
|
13
|
+
http,
|
|
14
|
+
configs,
|
|
15
|
+
register(apps) {
|
|
16
|
+
for (const [app, definition] of Object.entries(apps)) {
|
|
17
|
+
for (const [name, model] of Object.entries(definition.models ?? {})) {
|
|
18
|
+
const fullName = `${app}.${name}`
|
|
19
|
+
configs[fullName] = { ...model, app, name, fullName }
|
|
20
|
+
models.delete(fullName)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return registry
|
|
24
|
+
},
|
|
25
|
+
getConfig(name) {
|
|
26
|
+
if (!configs[name]) throw new Error(`model ${name} not found!`)
|
|
27
|
+
return configs[name]
|
|
28
|
+
},
|
|
29
|
+
get(name) {
|
|
30
|
+
if (!models.has(name))
|
|
31
|
+
models.set(name, AppModel(registry.getConfig(name), { http, views, loadViewsConfig }))
|
|
32
|
+
return models.get(name)
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
return registry.register(apps)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function AppModel(config, { http = createHttp(), views = {}, loadViewsConfig } = {}) {
|
|
39
|
+
const fullName = config.fullName ?? `${config.app}.${config.name}`
|
|
40
|
+
// Relative URL preserves deployment prefixes such as /tenant/api/.
|
|
41
|
+
const listUrl = config.url ?? `${config.app}/${config.name}/`
|
|
42
|
+
let pendingOptions
|
|
43
|
+
let pendingViews
|
|
44
|
+
const model = {
|
|
45
|
+
config,
|
|
46
|
+
name: config.name,
|
|
47
|
+
fullName,
|
|
48
|
+
appModel: fullName,
|
|
49
|
+
listUrl,
|
|
50
|
+
verboseName: config.verbose_name ?? config.name,
|
|
51
|
+
title_field: config.title_field ?? 'name',
|
|
52
|
+
getListUrl: () => listUrl,
|
|
53
|
+
getDetailUrl(id) {
|
|
54
|
+
if (id == null || id === '') throw new Error('详情请求需要主键')
|
|
55
|
+
return `${listUrl}${encodeURIComponent(id)}/`
|
|
56
|
+
},
|
|
57
|
+
loadOptions() {
|
|
58
|
+
if (config.rest_options) return Promise.resolve(config.rest_options)
|
|
59
|
+
if (!pendingOptions)
|
|
60
|
+
pendingOptions = http
|
|
61
|
+
.options(listUrl)
|
|
62
|
+
.then(({ data }) => {
|
|
63
|
+
if (
|
|
64
|
+
!data ||
|
|
65
|
+
typeof data !== 'object' ||
|
|
66
|
+
Array.isArray(data) ||
|
|
67
|
+
!Object.keys(data).length
|
|
68
|
+
) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`模型 ${fullName} 的 OPTIONS 返回空或无效元数据,请检查 API 代理是否拦截了 OPTIONS 请求`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
config.rest_options = data
|
|
74
|
+
return data
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
pendingOptions = undefined
|
|
78
|
+
})
|
|
79
|
+
return pendingOptions
|
|
80
|
+
},
|
|
81
|
+
loadViewsConfig() {
|
|
82
|
+
if (!pendingViews)
|
|
83
|
+
pendingViews = Promise.resolve()
|
|
84
|
+
.then(() => views[fullName] ?? (loadViewsConfig ? loadViewsConfig(fullName) : {}))
|
|
85
|
+
.catch((error) => {
|
|
86
|
+
pendingViews = undefined
|
|
87
|
+
throw error
|
|
88
|
+
})
|
|
89
|
+
return pendingViews
|
|
90
|
+
},
|
|
91
|
+
async fields(method = 'POST') {
|
|
92
|
+
return fieldsFromOptions(await model.loadOptions(), method)
|
|
93
|
+
},
|
|
94
|
+
loadObject(id) {
|
|
95
|
+
return http.get(model.getDetailUrl(id)).then((r) => r.data)
|
|
96
|
+
},
|
|
97
|
+
query(params = {}, url = listUrl) {
|
|
98
|
+
return http.get(url, { params }).then((r) => r.data)
|
|
99
|
+
},
|
|
100
|
+
async save(data, id, defaults = {}) {
|
|
101
|
+
const fields = await model.fields(id == null ? 'POST' : 'PATCH')
|
|
102
|
+
const payload = writableData({ ...defaults, ...data }, fields)
|
|
103
|
+
const response =
|
|
104
|
+
id == null
|
|
105
|
+
? await http.post(listUrl, payload)
|
|
106
|
+
: await http.patch(model.getDetailUrl(id), payload)
|
|
107
|
+
return response.data
|
|
108
|
+
},
|
|
109
|
+
destroy(id) {
|
|
110
|
+
return http.delete(model.getDetailUrl(id)).then((r) => r.data)
|
|
111
|
+
},
|
|
112
|
+
doAction(action, data = {}, method = 'post', id) {
|
|
113
|
+
if (!/^[\w-]+$/.test(action)) throw new Error('动作名只能包含字母、数字、下划线和连字符')
|
|
114
|
+
const url = `${id == null ? listUrl : model.getDetailUrl(id)}${action}/`
|
|
115
|
+
return http
|
|
116
|
+
.request({
|
|
117
|
+
url,
|
|
118
|
+
method,
|
|
119
|
+
...(['get', 'head'].includes(method.toLowerCase()) ? { params: data } : { data }),
|
|
120
|
+
})
|
|
121
|
+
.then((r) => r.data)
|
|
122
|
+
},
|
|
123
|
+
genEmptyDataFromRestOptions: emptyData,
|
|
124
|
+
}
|
|
125
|
+
return model
|
|
126
|
+
}
|
|
127
|
+
export const Register = createRegistry()
|