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,103 @@
1
+ <script setup>
2
+ import { ref, watch } from 'vue'
3
+ import { ElInput, ElButton, ElSelect, ElOption } from 'element-plus'
4
+ import Field from '../form/Field.vue'
5
+ import { useDjango } from '../../composables/context.js'
6
+ import { normalizeItems } from '../../core/metadata.js'
7
+ const props = defineProps({ appModel: String, items: Array })
8
+ const emit = defineEmits(['change', 'error'])
9
+ const { registry } = useDjango()
10
+ const form = ref({}),
11
+ fields = ref([]),
12
+ searchNames = ref([])
13
+ let generation = 0
14
+ watch(
15
+ () => [props.appModel, props.items],
16
+ async () => {
17
+ const current = ++generation
18
+ try {
19
+ const model = registry.get(props.appModel)
20
+ const [options, metadata, config] = await Promise.all([
21
+ model.loadOptions(),
22
+ model.fields(),
23
+ model.loadViewsConfig(),
24
+ ])
25
+ if (current !== generation) return
26
+ searchNames.value = options.actions?.SEARCH?.search_fields ?? []
27
+ fields.value = normalizeItems(
28
+ props.items ?? options.actions?.SEARCH?.filter_fields ?? [],
29
+ metadata,
30
+ ).map((field) => {
31
+ const configured = { ...field, ...config.search?.[field.name] }
32
+ const selection =
33
+ configured.model ||
34
+ configured.relateModel ||
35
+ configured.choices ||
36
+ configured.type === 'boolean'
37
+ return {
38
+ ...configured,
39
+ placeholder:
40
+ configured.placeholder ??
41
+ `${selection ? '请选择' : '请输入'}${configured.label || configured.name}`,
42
+ read_only: false,
43
+ required: false,
44
+ multiple: false,
45
+ }
46
+ })
47
+ form.value = {}
48
+ } catch (e) {
49
+ emit('error', e)
50
+ }
51
+ },
52
+ { immediate: true, deep: true },
53
+ )
54
+ function submit() {
55
+ emit(
56
+ 'change',
57
+ Object.fromEntries(
58
+ Object.entries(form.value).filter(([, value]) => value !== '' && value != null),
59
+ ),
60
+ )
61
+ }
62
+ function reset() {
63
+ form.value = {}
64
+ submit()
65
+ }
66
+ </script>
67
+ <template>
68
+ <form
69
+ class="vd-toolbar"
70
+ @submit.prevent="submit"
71
+ >
72
+ <ElInput
73
+ v-if="searchNames.length"
74
+ v-model="form.search"
75
+ clearable
76
+ :placeholder="`搜索${searchNames.join('、')}`"
77
+ aria-label="搜索记录"
78
+ />
79
+ <div
80
+ v-for="field in fields"
81
+ :key="field.name"
82
+ >
83
+ <ElSelect
84
+ v-if="field.type === 'boolean'"
85
+ v-model="form[field.name]"
86
+ clearable
87
+ :placeholder="field.placeholder"
88
+ :aria-label="field.label || field.name"
89
+ ><ElOption
90
+ label="是"
91
+ :value="true" /><ElOption
92
+ label="否"
93
+ :value="false"
94
+ /></ElSelect>
95
+ <Field
96
+ v-else
97
+ :field="field"
98
+ v-model="form[field.name]"
99
+ />
100
+ </div>
101
+ <ElButton native-type="submit">搜索</ElButton><ElButton @click="reset">重置</ElButton>
102
+ </form>
103
+ </template>
@@ -0,0 +1,91 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from 'vue'
3
+ import { ElSelect, ElOption, ElAlert } from 'element-plus'
4
+ import { useDjango } from '../../composables/context.js'
5
+ const props = defineProps({
6
+ appModel: String,
7
+ modelValue: [String, Number, Array],
8
+ field: { type: Object, default: () => ({}) },
9
+ pageSize: { type: Number, default: 30 },
10
+ })
11
+ const emit = defineEmits(['update:modelValue', 'error'])
12
+ const { registry } = useDjango()
13
+ const rows = ref([]),
14
+ selected = ref([]),
15
+ loading = ref(false),
16
+ error = ref('')
17
+ let generation = 0
18
+ const idField = computed(() => props.field.idField || 'id')
19
+ const options = computed(() => [
20
+ ...new Map([...selected.value, ...rows.value].map((row) => [row[idField.value], row])).values(),
21
+ ])
22
+ async function load(search = '') {
23
+ const current = ++generation
24
+ loading.value = true
25
+ error.value = ''
26
+ try {
27
+ const model = registry.get(props.appModel)
28
+ const ids = Array.isArray(props.modelValue)
29
+ ? props.modelValue
30
+ : props.modelValue == null
31
+ ? []
32
+ : [props.modelValue]
33
+ const [result, values] = await Promise.all([
34
+ model.query({
35
+ ...props.field.baseQueries,
36
+ search: search || undefined,
37
+ page_size: props.pageSize,
38
+ }),
39
+ ids.length
40
+ ? model.query({
41
+ ...props.field.baseQueries,
42
+ [`${idField.value}__in`]: ids,
43
+ page_size: ids.length,
44
+ })
45
+ : [],
46
+ ])
47
+ if (current !== generation) return
48
+ rows.value = result.results ?? result
49
+ selected.value = values.results ?? values
50
+ } catch (e) {
51
+ if (current === generation) {
52
+ error.value = e.message
53
+ emit('error', e)
54
+ }
55
+ } finally {
56
+ if (current === generation) loading.value = false
57
+ }
58
+ }
59
+ watch(
60
+ () => [props.appModel, props.modelValue, props.field.baseQueries],
61
+ () => load(),
62
+ { immediate: true, deep: true },
63
+ )
64
+ </script>
65
+ <template>
66
+ <ElSelect
67
+ :model-value="modelValue"
68
+ :multiple="field.multiple"
69
+ :loading="loading"
70
+ filterable
71
+ remote
72
+ clearable
73
+ :remote-method="load"
74
+ :placeholder="field.placeholder || `请选择${field.label || ''}`"
75
+ style="width: 100%"
76
+ @update:model-value="emit('update:modelValue', $event === '' ? null : $event)"
77
+ >
78
+ <ElOption
79
+ v-for="row in options"
80
+ :key="row[idField]"
81
+ :value="row[idField]"
82
+ :label="String(row.__str__ ?? row.name ?? row.title ?? row[idField])"
83
+ />
84
+ </ElSelect>
85
+ <ElAlert
86
+ v-if="error"
87
+ :title="error"
88
+ type="error"
89
+ :closable="false"
90
+ />
91
+ </template>
@@ -0,0 +1,335 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from 'vue'
3
+ import { ElTable, ElTableColumn, ElButton, ElPagination, ElAlert } from 'element-plus'
4
+ import { Cell, CellGroup, Search, Button, Pagination, NoticeBar } from 'vant'
5
+ import { useDjango } from '../../composables/context.js'
6
+ import { normalizeItems } from '../../core/metadata.js'
7
+ import ModelSearch from './Search.vue'
8
+ import TableWidget from '../table/Widget.vue'
9
+ import Drawer from '../layout/Drawer.vue'
10
+ import ModelCreate from './Create.vue'
11
+ const props = defineProps({
12
+ appModel: { type: String, required: true },
13
+ items: [Array, String],
14
+ baseQueries: { type: Object, default: () => ({}) },
15
+ pageSize: { type: Number, default: 10 },
16
+ mobile: Boolean,
17
+ rowActions: Array,
18
+ dblClickAction: String,
19
+ createMode: { type: String, default: 'drawer' },
20
+ createDefaults: Object,
21
+ createDrawerSize: { type: String, default: '66%' },
22
+ })
23
+ const emit = defineEmits([
24
+ 'loaded',
25
+ 'edit',
26
+ 'create',
27
+ 'created',
28
+ 'error',
29
+ 'row-dblclick',
30
+ 'field-change',
31
+ ])
32
+ const createDrawer = ref()
33
+ const { registry, auth } = useDjango()
34
+ const rows = ref([]),
35
+ fields = ref([]),
36
+ count = ref(0),
37
+ search = ref(''),
38
+ page = ref(1),
39
+ loading = ref(false),
40
+ message = ref('')
41
+ const views = ref({})
42
+ const queries = ref({})
43
+ const title = computed(() => registry.getConfig(props.appModel).verbose_name ?? props.appModel)
44
+ let generation = 0
45
+ async function create() {
46
+ emit('create')
47
+ if (props.createMode === 'event') return
48
+ try {
49
+ await createDrawer.value.open({
50
+ component: ModelCreate,
51
+ title: `创建${title.value}`,
52
+ size: props.mobile ? '100%' : props.createDrawerSize,
53
+ context: {
54
+ appModel: props.appModel,
55
+ defaults: { ...props.baseQueries, ...props.createDefaults },
56
+ mobile: props.mobile,
57
+ onError: (error) => emit('error', error),
58
+ },
59
+ onDone: async (event) => {
60
+ emit('created', event)
61
+ await load()
62
+ },
63
+ })
64
+ } catch (error) {
65
+ message.value = error.message
66
+ emit('error', error)
67
+ }
68
+ }
69
+ async function load() {
70
+ const current = ++generation
71
+ loading.value = true
72
+ message.value = ''
73
+ try {
74
+ const model = registry.get(props.appModel)
75
+ const [metadata, config] = await Promise.all([model.fields(), model.loadViewsConfig()])
76
+ const result = await model.query({
77
+ ...queries.value,
78
+ search: search.value || undefined,
79
+ page: page.value,
80
+ page_size: props.pageSize,
81
+ ...config.list?.baseQueries,
82
+ ...props.baseQueries,
83
+ })
84
+ if (current !== generation) return
85
+ views.value = config
86
+ fields.value = normalizeItems(
87
+ props.items ?? config.list?.items ?? config.list?.table ?? 'all',
88
+ metadata,
89
+ )
90
+ rows.value = Array.isArray(result) ? result : result.results
91
+ count.value = Array.isArray(result) ? result.length : result.count
92
+ if (!Array.isArray(rows.value) || !Number.isFinite(count.value))
93
+ throw new Error('首轮列表需要 DRF PageNumberPagination 的 count/results 或数组响应')
94
+ emit('loaded', result)
95
+ } catch (error) {
96
+ if (current === generation) {
97
+ rows.value = []
98
+ count.value = 0
99
+ message.value = error.message
100
+ emit('error', error)
101
+ }
102
+ } finally {
103
+ if (current === generation) loading.value = false
104
+ }
105
+ }
106
+ function onSearch() {
107
+ if (page.value !== 1) page.value = 1
108
+ else load()
109
+ }
110
+ function filterChanged(value) {
111
+ queries.value = value
112
+ search.value = value.search || ''
113
+ onSearch()
114
+ }
115
+ function onRowDblClick(row, column, event) {
116
+ emit('row-dblclick', row, column, event)
117
+ const name = props.dblClickAction ?? views.value.list?.dblClickAction ?? 'edit'
118
+ if (!name) return
119
+ // Interactive controls handle their own navigation and actions.
120
+ if (event?.target?.closest?.('a, button, input, select, textarea, [role="button"]')) return
121
+ if (name === 'edit') emit('edit', row)
122
+ else {
123
+ const action = actions.value.find((item) => item.name === name)
124
+ if (action && (!action.show || action.show({ row }))) rowAction(action, row)
125
+ }
126
+ }
127
+ async function rowAction(action, row) {
128
+ try {
129
+ const model = registry.get(props.appModel)
130
+ if (typeof action.do === 'function') await action.do({ row, model, registry })
131
+ else if (action.api || action.name)
132
+ await model.doAction(
133
+ action.api || action.name,
134
+ action.context,
135
+ action.method ?? 'post',
136
+ row[model.config.idField ?? 'id'],
137
+ )
138
+ else throw new Error('首轮行操作需要 do 函数或 api 配置')
139
+ await load()
140
+ } catch (error) {
141
+ message.value = error.message
142
+ emit('error', error)
143
+ }
144
+ }
145
+ const actions = computed(() =>
146
+ (
147
+ props.rowActions ??
148
+ views.value.list?.rowActions ??
149
+ views.value.list?.options?.remoteTable?.rowActions ??
150
+ []
151
+ )
152
+ .filter((action) => {
153
+ if (!action.permission) return true
154
+ const permissions = auth?.state.user?.model_permissions?.[props.appModel]
155
+ return (
156
+ auth?.state.user?.is_superuser ||
157
+ (Array.isArray(permissions) && permissions.includes(action.permission))
158
+ )
159
+ })
160
+ .map((action) => ({
161
+ ...action,
162
+ label: action.label || action.title || action.verbose_name || action.name,
163
+ })),
164
+ )
165
+ watch(
166
+ () => [props.appModel, props.baseQueries, props.pageSize],
167
+ () => {
168
+ page.value = 1
169
+ load()
170
+ },
171
+ { immediate: true, deep: true },
172
+ )
173
+ watch(page, load)
174
+ defineExpose({ refresh: load, load })
175
+ </script>
176
+ <template>
177
+ <section :aria-busy="loading">
178
+ <Drawer
179
+ ref="createDrawer"
180
+ @error="emit('error', $event)"
181
+ />
182
+ <div class="vd-toolbar">
183
+ <h2>{{ title }}</h2>
184
+ <Button
185
+ v-if="mobile"
186
+ type="primary"
187
+ size="small"
188
+ @click="create"
189
+ >新增</Button
190
+ >
191
+ <ElButton
192
+ v-else
193
+ type="primary"
194
+ @click="create"
195
+ >新增</ElButton
196
+ >
197
+ </div>
198
+ <Search
199
+ v-if="mobile"
200
+ v-model="search"
201
+ placeholder="搜索记录"
202
+ @search="onSearch"
203
+ @clear="onSearch"
204
+ />
205
+ <ModelSearch
206
+ v-else
207
+ :app-model="appModel"
208
+ @change="filterChanged"
209
+ @error="message = $event.message"
210
+ />
211
+ <NoticeBar
212
+ v-if="message && mobile"
213
+ :text="message"
214
+ />
215
+ <ElAlert
216
+ v-else-if="message"
217
+ :title="message"
218
+ type="error"
219
+ :closable="false"
220
+ />
221
+ <p v-if="loading">加载中…</p>
222
+ <template v-else-if="mobile">
223
+ <p v-if="!rows.length && !message">暂无数据</p>
224
+ <CellGroup
225
+ v-for="row in rows"
226
+ :key="row[registry.getConfig(appModel).idField ?? 'id']"
227
+ inset
228
+ class="vd-card"
229
+ >
230
+ <Cell
231
+ v-for="field in fields"
232
+ :key="field.name"
233
+ :title="field.label || field.name"
234
+ >
235
+ <template #value>
236
+ <slot
237
+ :name="`column-${field.name}`"
238
+ :row="row"
239
+ :field="field"
240
+ >
241
+ <TableWidget
242
+ :value="row"
243
+ :field="field"
244
+ mobile
245
+ @change="emit('field-change', { row, field, value: $event })"
246
+ />
247
+ </slot>
248
+ </template>
249
+ </Cell>
250
+ <Cell
251
+ ><template #value
252
+ ><Button
253
+ size="small"
254
+ @click="emit('edit', row)"
255
+ >编辑</Button
256
+ ><Button
257
+ v-for="action in actions"
258
+ :key="action.name"
259
+ size="small"
260
+ @click="rowAction(action, row)"
261
+ >{{ action.label || action.name }}</Button
262
+ ></template
263
+ ></Cell
264
+ >
265
+ </CellGroup>
266
+ <Pagination
267
+ v-model="page"
268
+ :total-items="count"
269
+ :items-per-page="pageSize"
270
+ />
271
+ </template>
272
+ <template v-else>
273
+ <ElTable
274
+ :data="rows"
275
+ stripe
276
+ @row-dblclick="onRowDblClick"
277
+ >
278
+ <ElTableColumn
279
+ v-for="field in fields"
280
+ :key="field.name"
281
+ :prop="field.name"
282
+ :label="field.label || field.name"
283
+ :width="field.width"
284
+ :min-width="field.minWidth ?? field['min-width'] ?? 120"
285
+ :align="
286
+ field.align ??
287
+ (['integer', 'decimal', 'float', 'number', 'percent'].includes(field.type)
288
+ ? 'right'
289
+ : 'left')
290
+ "
291
+ :fixed="field.fixed"
292
+ :show-overflow-tooltip="field.showOverflowTooltip"
293
+ >
294
+ <template #default="{ row, $index }"
295
+ ><slot
296
+ :name="`column-${field.name}`"
297
+ :row="row"
298
+ :field="field"
299
+ :context="{ row, $index }"
300
+ ><TableWidget
301
+ :value="row"
302
+ :field="field"
303
+ :context="{ row, $index }"
304
+ @change="emit('field-change', { row, field, value: $event })"
305
+ /> </slot
306
+ ></template>
307
+ </ElTableColumn>
308
+ <ElTableColumn
309
+ label="操作"
310
+ min-width="140"
311
+ ><template #default="{ row }"
312
+ ><ElButton
313
+ link
314
+ type="primary"
315
+ @click="emit('edit', row)"
316
+ >编辑</ElButton
317
+ ><ElButton
318
+ v-for="action in actions"
319
+ :key="action.name"
320
+ link
321
+ @click="rowAction(action, row)"
322
+ >{{ action.label || action.name }}</ElButton
323
+ ></template
324
+ ></ElTableColumn
325
+ >
326
+ </ElTable>
327
+ <ElPagination
328
+ v-model:current-page="page"
329
+ :total="count"
330
+ :page-size="pageSize"
331
+ layout="total, prev, pager, next"
332
+ />
333
+ </template>
334
+ </section>
335
+ </template>