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,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 @@
|
|
|
1
|
+
export { normalizeItems as default } from '../core/metadata.js'
|