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,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
+ }
@@ -0,0 +1,7 @@
1
+ import { inject } from 'vue'
2
+
3
+ export const TabKey = Symbol('vue3-django-tab')
4
+
5
+ export function useViewTab() {
6
+ return inject(TabKey, null)
7
+ }
@@ -0,0 +1,2 @@
1
+ import { Register } from '../core/registry.js'
2
+ export default Register.http
@@ -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
+ }
@@ -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()