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,74 @@
1
+ import qs from 'qs'
2
+
3
+ export function relationIds(value) {
4
+ const ids = typeof value === 'string' ? JSON.parse(value) : (value ?? [])
5
+ if (!Array.isArray(ids)) throw new Error('多对多关联字段必须是主键数组')
6
+ return ids
7
+ }
8
+
9
+ export async function resolveRelation(item, parent, registry) {
10
+ const definition = typeof item === 'string' ? { name: item } : item
11
+ if (!definition?.name) throw new Error('关联配置缺少模型 name')
12
+ const colon = definition.name.indexOf(':')
13
+ const name = colon < 0 ? definition.name : definition.name.slice(0, colon)
14
+ const query = colon < 0 ? undefined : definition.name.slice(colon + 1)
15
+ const model = registry.get(name),
16
+ parentModel = registry.get(parent.appModel)
17
+ const [fields, parentFields, options, parentOptions] = await Promise.all([
18
+ model.fields(),
19
+ parentModel.fields(),
20
+ model.loadOptions(),
21
+ parentModel.loadOptions(),
22
+ ])
23
+ const parentId = parent.id ?? parent.data?.[parentModel.config.idField || 'id']
24
+ if (parentId == null) throw new Error('关联列表需要已保存的父记录')
25
+ let filters = definition.parentRelationQuery,
26
+ defaults = {},
27
+ multipleField
28
+ if (query !== undefined)
29
+ filters = Object.fromEntries(
30
+ Object.entries(qs.parse(query)).map(([key, source]) => {
31
+ const value = source === '' || source === 'id' ? parentId : parent.data?.[source]
32
+ if (value == null) throw new Error(`关联查询 ${key} 缺少父字段 ${source || 'id'}`)
33
+ return [key, value]
34
+ }),
35
+ )
36
+ if (!filters) {
37
+ multipleField = definition.parentMultipleRelationFieldName
38
+ ? parentFields[definition.parentMultipleRelationFieldName]
39
+ : Object.values(parentFields).find((field) => field.model === name && field.multiple)
40
+ if (multipleField) {
41
+ if (!multipleField.multiple || multipleField.model !== name)
42
+ throw new Error('指定的父字段不是此模型的多对多关联')
43
+ const ids = relationIds(parent.data?.[multipleField.name])
44
+ filters = { [`${model.config.idField || 'id'}__in`]: ids.length ? ids : [0] }
45
+ } else {
46
+ const field = Object.values(fields).find(
47
+ (field) => field.model === parent.appModel && !field.multiple,
48
+ )
49
+ if (field) filters = { [field.name]: parentId }
50
+ else if (options.generic_foreign_key) {
51
+ const { ct_field, fk_field } = options.generic_foreign_key
52
+ if (!fields[fk_field] || parentOptions.content_type_id == null)
53
+ throw new Error('通用外键缺少 fk_field 或父模型 content_type_id')
54
+ filters = { [ct_field]: parentOptions.content_type_id, [fk_field]: parentId }
55
+ } else throw new Error(`无法确定 ${name} 的关联字段,请显式配置 ${name}:字段=id`)
56
+ }
57
+ }
58
+ if (!multipleField)
59
+ defaults = Object.fromEntries(
60
+ Object.entries(filters).filter(
61
+ ([key]) => fields[key] && !fields[key].read_only && !key.includes('__'),
62
+ ),
63
+ )
64
+ return {
65
+ ...definition,
66
+ name,
67
+ model,
68
+ multipleField,
69
+ defaults: { ...defaults, ...definition.defaults },
70
+ label: definition.label || model.config.verbose_name || name,
71
+ icon: definition.icon || model.config.icon,
72
+ baseQueries: { ...definition.baseQueries, ...filters },
73
+ }
74
+ }
@@ -0,0 +1,48 @@
1
+ import { displayValue } from './metadata.js'
2
+
3
+ export function fieldValue(row, name) {
4
+ return String(name)
5
+ .split('.')
6
+ .reduce((value, key) => value?.[key], row)
7
+ }
8
+
9
+ export function safeUrl(value) {
10
+ if (typeof value !== 'string' || !value.trim()) return undefined
11
+ const url = value.trim()
12
+ if (/^(https?:|mailto:|tel:)/i.test(url) || /^(\/[^/]|\.\.?\/)/.test(url)) return url
13
+ return undefined
14
+ }
15
+
16
+ export function tableDisplay(field, row) {
17
+ const value = fieldValue(row, field.name)
18
+ if (field.formatter) return field.formatter(row, field.name, value)
19
+ if (value == null || value === '') return '—'
20
+ if (field.choices) return displayValue(field, value)
21
+ if (['integer', 'decimal', 'float', 'number', 'percent'].includes(field.type)) {
22
+ const number = Number(value)
23
+ if (!Number.isFinite(number)) return String(value)
24
+ if (field.type === 'percent') return `${Number((number * 100).toFixed(2))}%`
25
+ return number.toLocaleString('zh-CN', { maximumFractionDigits: 20 })
26
+ }
27
+ if (field.child && Array.isArray(value)) {
28
+ return value
29
+ .map((item) => {
30
+ if (item == null || typeof item !== 'object') return displayValue(field.child, item)
31
+ return (
32
+ Object.entries(field.child.children ?? {})
33
+ .map(([name, child]) => displayValue(child, item[name]))
34
+ .join(',') || JSON.stringify(item)
35
+ )
36
+ })
37
+ .join('\n')
38
+ }
39
+ return displayValue(field, value)
40
+ }
41
+
42
+ export function tableDate(value, timestamp = false) {
43
+ if (value == null || value === '') return ''
44
+ const numeric = timestamp || typeof value === 'number' || /^\d+$/.test(String(value))
45
+ const time = numeric ? Number(value) * (Number(value) < 1e10 ? 1000 : 1) : value
46
+ const date = new Date(time)
47
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN')
48
+ }
@@ -0,0 +1,23 @@
1
+ // Pass import.meta.glob('./views/**/config.js') from the consuming Vite app.
2
+ export function createViewsConfigLoader(modules) {
3
+ return async (fullName) => {
4
+ const suffix = `/views/${fullName.replace('.', '/')}/config.js`
5
+ const entries = Object.entries(modules).filter(([path]) => path.endsWith(suffix))
6
+ if (!entries.length) return {}
7
+ if (entries.length > 1) throw new Error(`模型 ${fullName} 有多个视图配置文件`)
8
+ const entry = entries[0][1]
9
+ const module = typeof entry === 'function' ? await entry() : entry
10
+ return module.default ?? module
11
+ }
12
+ }
13
+
14
+ export function createRelationViewLoader(modules) {
15
+ return async (appModel, view) => {
16
+ if (!/^[\w-]+(?:\/[\w-]+)*$/.test(view)) throw new Error('关联视图路径无效')
17
+ const suffix = `/views/${appModel.replace('.', '/')}/${view}.vue`
18
+ const entries = Object.entries(modules).filter(([path]) => path.endsWith(suffix))
19
+ if (entries.length !== 1) throw new Error(`关联视图 ${appModel}/${view} 未找到或重复`)
20
+ const entry = entries[0][1]
21
+ return typeof entry === 'function' ? entry() : entry
22
+ }
23
+ }
package/src/index.js ADDED
@@ -0,0 +1,31 @@
1
+ import './style.css'
2
+ export { createHttp, DrfError, joinErrors } from './core/http.js'
3
+ export { AppModel, Register, createRegistry } from './core/registry.js'
4
+ export {
5
+ normalizeItems,
6
+ fieldsFromOptions,
7
+ emptyData,
8
+ writableData,
9
+ displayValue,
10
+ } from './core/metadata.js'
11
+ export { createDjango, useDjango } from './composables/context.js'
12
+ export { createAuth } from './core/auth.js'
13
+ export { createDjangoRouter, genModelRouters, safeRedirect } from './router/index.js'
14
+ export { default as ModelForm } from './components/model/Form.vue'
15
+ export { default as ModelTable } from './components/model/Table.vue'
16
+ export { default as MobileForm } from './components/mobile/Form.vue'
17
+ export { default as ViewTabs } from './components/layout/ViewTabs.vue'
18
+ export { default as Layout } from './components/layout/Layout.vue'
19
+ export { default as Drawer } from './components/layout/Drawer.vue'
20
+ export { default as Actions } from './components/layout/Actions.vue'
21
+ export { useDrawer, createDrawerViewLoader } from './composables/drawer.js'
22
+ export { default as SideBar } from './components/layout/SideBar.vue'
23
+ export { genMenusFromApps } from './core/menus.js'
24
+ export { createViewsConfigLoader, createRelationViewLoader } from './core/views.js'
25
+ export { default as ModelSearch } from './components/model/Search.vue'
26
+ export { default as ModelRelations } from './components/model/Relations.vue'
27
+ export { default as ModelSelect } from './components/model/Select.vue'
28
+ export { default as ForeignKey } from './components/widgets/ForeignKey.vue'
29
+ export { default as TableWidget } from './components/table/Widget.vue'
30
+ export { useViewTab } from './composables/tab.js'
31
+ export { default as Form } from './components/form/Form.vue'
@@ -0,0 +1,78 @@
1
+ import { createRouter, createWebHashHistory } from 'vue-router'
2
+
3
+ export function safeRedirect(value, fallback = '/') {
4
+ return typeof value === 'string' &&
5
+ value.startsWith('/') &&
6
+ !value.startsWith('//') &&
7
+ !value.includes('\\') &&
8
+ !value.startsWith('/auth/login')
9
+ ? value
10
+ : fallback
11
+ }
12
+
13
+ export function createDjangoRouter({
14
+ routes = [],
15
+ auth,
16
+ history = createWebHashHistory(),
17
+ loginPath = '/auth/login/',
18
+ }) {
19
+ const router = createRouter({ history, routes })
20
+ if (auth)
21
+ router.beforeEach(async (to) => {
22
+ if (
23
+ to.path.replace(/\/$/, '') === loginPath.replace(/\/$/, '') ||
24
+ to.meta.loginRequired === false
25
+ )
26
+ return true
27
+ if (!auth.state.user) {
28
+ try {
29
+ await auth.getUserInfo()
30
+ } catch (error) {
31
+ if (![401, 403].includes(error.code ?? error.response?.status)) throw error
32
+ auth.removeToken()
33
+ return { path: loginPath, query: { redirect: to.fullPath } }
34
+ }
35
+ }
36
+ return true
37
+ })
38
+ return router
39
+ }
40
+
41
+ export function genModelRouters(apps, components) {
42
+ const views =
43
+ typeof components === 'object' && components.list
44
+ ? components
45
+ : { list: components, create: components, edit: components }
46
+ return Object.entries(apps).flatMap(([app, definition]) =>
47
+ definition.hidden === true || definition.hidden === 'true'
48
+ ? []
49
+ : Object.entries(definition.models ?? {}).flatMap(([name, model]) => {
50
+ const path = `/${app}/${name}/`,
51
+ appModel = `${app}.${name}`,
52
+ title = model.verbose_name ?? name
53
+ return [
54
+ {
55
+ path,
56
+ name: `${app}-${name}-list`,
57
+ component: views.list,
58
+ props: { appModel, mode: 'list' },
59
+ meta: { title: `${title}列表`, model },
60
+ },
61
+ {
62
+ path: `${path}add/`,
63
+ name: `${app}-${name}-add`,
64
+ component: views.create ?? views.edit,
65
+ props: { appModel, mode: 'create' },
66
+ meta: { title: `新增${title}`, model },
67
+ },
68
+ {
69
+ path: `${path}:id/`,
70
+ name: `${app}-${name}-edit`,
71
+ component: views.edit,
72
+ props: (route) => ({ appModel, mode: 'edit', id: route.params.id }),
73
+ meta: { title: `编辑${title}`, model },
74
+ },
75
+ ]
76
+ }),
77
+ )
78
+ }
package/src/style.css ADDED
@@ -0,0 +1,23 @@
1
+ .vd-toolbar {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 12px;
5
+ margin: 16px 0;
6
+ }
7
+ .vd-toolbar h2 {
8
+ flex: 1;
9
+ margin: 0;
10
+ font-size: 20px;
11
+ }
12
+ .vd-toolbar .el-input {
13
+ max-width: 360px;
14
+ }
15
+ .vd-card {
16
+ margin-bottom: 16px;
17
+ }
18
+ .el-pagination {
19
+ margin-top: 20px;
20
+ }
21
+ .el-alert {
22
+ margin-bottom: 16px;
23
+ }
@@ -0,0 +1,2 @@
1
+ export { AppModel as default, AppModel, Register, createRegistry } from '../core/registry.js'
2
+ export { joinErrors } from '../core/http.js'
@@ -0,0 +1 @@
1
+ export { normalizeItems as default } from '../core/metadata.js'