vue3-django 0.1.5 → 0.1.7

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 CHANGED
@@ -1,54 +1,112 @@
1
1
  # vue3-django
2
2
 
3
- 继承 `vue-django` 的理念:借鉴 Django Admin 的自动化思想,通过 DRF 元数据和少量业务配置实现前端极简定制。当前版本为 0.1.0,使用 Vue 3 + Element Plus + Vant,渐进迁移模型 CRUD、路由、登录、布局与字段控件。
3
+ 面向 Django REST Framework 的 Vue 3 管理后台框架。后端提供字段元数据,前端声明模型和少量页面配置,即可生成列表、表单、路由、菜单与编辑标签页。
4
4
 
5
- ## 运行
5
+ 核心目标是减少重复的 CRUD 页面代码,同时保留 Vue 组件、插槽和业务路由的定制能力。PC 后台使用 Element Plus;移动端通过独立应用使用通用控件,不与后台布局混合。
6
6
 
7
- Node >= 22.12,执行:
7
+ ## 适合你的项目吗?
8
+
9
+ 适合已有 Django / DRF API,需要多个模型管理页面,并希望统一搜索、编辑、关联和操作体验的内部管理系统。
10
+
11
+ 你仍需提供业务 API、认证与权限校验。框架不生成 Django 后端,也不是拖拽式低代码平台;任意 REST API 不会自动获得全部模型功能,但可以使用 RemoteTable、Form 等通用组件单独接入。
12
+
13
+ 当前为持续完善中的框架。核心前端流程已有自动化测试,接入具体业务前仍需验证真实 API 和权限。版本见 [package.json](package.json)。
14
+
15
+ ## 能力概览
16
+
17
+ | 能力 | 框架提供 | 业务需要提供 |
18
+ | ---------- | --------------------------------------------- | ---------------------------------- |
19
+ | 管理后台 | 默认布局、首页、菜单、登录页、路由和多标签页 | 模型注册、认证接口、品牌与页面定制 |
20
+ | 模型列表 | 分页、搜索、排序、列控件、双击编辑 | 列表 API、字段与搜索元数据 |
21
+ | 模型表单 | 新建抽屉、编辑页、校验、服务端字段错误 | OPTIONS、详情与保存 API |
22
+ | 关联数据 | 外键选择与跳转、多对多、通用关联、关联列表 | 关联模型与配置、相应 API |
23
+ | 操作 | 表头与行操作、批处理、确认、异步状态、抽屉 | 动作逻辑、接口或业务组件 |
24
+ | 媒体 | 图片/文件上传、VideoCover、表格图片与视频展示 | 上传签名与确认接口、存储服务 |
25
+ | 定制 | 字段配置、插槽、自定义控件、模型页面和路由 | 特殊业务 UI |
26
+ | 独立移动端 | Vant 示例、通用 Form 和 RemoteTable | 自有布局与业务数据源 |
27
+
28
+ ModelTable、ModelForm 和默认模型页面固定使用 PC 控件。通用 Table、RemoteTable、Form 保留移动能力。
29
+
30
+ ## 架构:元数据 + 配置 + 可替换组件
31
+
32
+ 三个层次各司其职:
33
+
34
+ - **数据层**:HTTP 客户端与模型 registry 负责 OPTIONS、查询和保存,每个应用可使用独立 registry。
35
+ - **视图配置**:apps.js 声明模型;每个模型的 config.js 指定列表、表单、搜索和关联的展示方式。字段配置覆盖后端元数据,不要求重新声明所有字段。
36
+ - **组件层**:默认页面组合 ModelTable、ModelForm 和布局;模型组件复用 RemoteTable、Form 等通用组件。复杂业务可替换组件,不必放弃整个框架。
37
+
38
+ 默认入口 `createDjangoApp` 组装这些层次;已有 Vue 应用也可只使用 `createRegistry`、`createDjango` 和需要的组件。
39
+
40
+ ## 先体验示例
41
+
42
+ 需要 Node >= 22.12。在仓库根目录运行:
8
43
 
9
44
  ```sh
10
45
  npm install
11
- npm run dev
12
- npm run dev:mobile
13
- npm test
14
- npm run build
15
- npm run build:demo
16
- npm run build:mobile
46
+ npm run dev:dashboard
17
47
  ```
18
48
 
19
- 开发入口是 PC 模型界面的本地演示,使用 Axios adapter 模拟 OPTIONS、分页、POST、PATCH、行操作与 400 错误。数据只在内存保存,刷新重置;不需要访问旧项目或生产后端。输入已有项目名称可验证后端字段错误。
49
+ 访问终端输出的地址,默认是 [http://127.0.0.1:5173/](http://127.0.0.1:5173/)。dashboard 默认使用内存 mock,不需要 Django;刷新重置数据,输入已有项目名称可体验服务端字段错误。
50
+
51
+ | 命令 | 用途 |
52
+ | ------------------------- | ----------------------------- |
53
+ | `npm run dev:dashboard` | PC 示例,默认端口 5173 |
54
+ | `npm run dev:mobile` | 独立移动示例,默认端口 5174 |
55
+ | `npm test` | 单元与组件测试 |
56
+ | `npm run build` | 构建框架到 dist |
57
+ | `npm run build:dashboard` | 构建 PC 示例到 dist-dashboard |
58
+ | `npm run build:mobile` | 构建移动示例到 dist-mobile |
59
+
60
+ 两个示例在 examples/dashboard、examples/mobile 中,各有入口、路由和 Vite 配置,共享仓库依赖及框架源码。移动示例使用独立内存任务数据,不连接真实 API。`npm run dev` 和 `npm run build:demo` 是 dashboard 的兼容命令。端口被占用时以终端输出为准。
20
61
 
21
- 访问 [本地预览](http://127.0.0.1:5173/)。默认 mock 模式提供 demo/crm;真实 API 模式需先启动 Django(默认 `http://127.0.0.1:8000`),再运行:
62
+ ### 使用真实 API
63
+
64
+ 先启动符合下文接口约定的 Django 服务,再启动 dashboard:
22
65
 
23
66
  ```sh
24
- VITE_REAL_API=true npm run dev -- --port 5173
67
+ VITE_REAL_API=true npm run dev:dashboard -- --port 5173
25
68
  ```
26
69
 
27
- 真实模式登录入口为 `/#/auth/login/`,默认首页为 `/#/home/`,类别列表为 `/#/course/category/`。
28
- Vite 将 `/api` 代理到 Django,包括用于读取元数据的 OPTIONS 请求。
29
- 如果端口已占用,请以终端输出的地址为准。
70
+ 示例代理将 /api 转发到 http://127.0.0.1:8000,包括 OPTIONS。登录路径为 `/#/auth/login/`,首页为 `/#/home/`,类别列表为 `/#/course/category/`。
30
71
 
31
- ## 两个独立示例项目
72
+ 真实模式的 apps.js 提供多个业务模型用于接入验证;注册配置不代表所有接口、动作页面和权限已经实现或验收。
32
73
 
33
- - `examples/dashboard`:PC 管理后台,使用默认布局、登录、sidebar、tabs 和模型列表/表单;可切换真实 Django API。
34
- - `examples/mobile`:独立 Vant 应用,使用通用 Form、RemoteTable,自有路由和内存任务数据;不复用 dashboard 页面,不连接真实 API。
74
+ ## 在新项目中接入
35
75
 
36
- 先在仓库根目录安装依赖。`npm run dev:dashboard` / `npm run dev:mobile` 分别启动两个示例;也可进入对应目录运行 `npm run dev`。两者各自提供 package.json、index.html、main.js 与 Vite 配置,共享仓库依赖和库源码。
37
- `npm run build:dashboard` / `npm run build:mobile` 分别输出 dist-dashboard / dist-mobile。`npm run dev` 和 `npm run build:demo` 保留为 dashboard 的兼容命令。
38
- 移动示例仅用于展示通用控件能力,不通过 mobile 开关切换管理后台。
76
+ 以下示例假设已有 Vue 3 + Vite 项目、Vue 插件配置和 HTML 中的 `<div id="app"></div>`。
39
77
 
40
- dashboard 的 `examples/dashboard/apps.js` 独立维护模型注册配置。真实模式已迁入原 dashboard 的
41
- 14 个 app、39 个 model,保留名称、图标、hidden、title_field 和动作配置。
42
- 这些配置不代表对应旧业务页面、动作和接口均已迁移或验收;无专用 config.js 的模型使用元数据默认视图。
43
- demo 加载 Font Awesome 4 样式以显示原配置中的图标名称。
78
+ 安装框架与 peer dependencies;使用本地包时先构建,再通过本地依赖接入:
44
79
 
45
- ## 默认应用入口(推荐)
80
+ ```sh
81
+ npm install vue3-django vue element-plus vant
82
+ ```
46
83
 
47
- 宿主只需 HTML 中的 `<div id="app"></div>`、下面的 main.js,以及 apps/config 业务配置:
84
+ 当前框架仍有 Vant 依赖,即使仅使用 PC 控件也需安装;PC 页面不必引入 Vant CSS。使用移动控件时另行加载 `vant/lib/index.css`。
85
+
86
+ ### 1. 注册模型
87
+
88
+ `src/apps.js`:
89
+
90
+ ```js
91
+ export default {
92
+ crm: {
93
+ verbose_name: '客户管理',
94
+ models: {
95
+ customer: {
96
+ verbose_name: '客户',
97
+ title_field: 'name',
98
+ },
99
+ },
100
+ },
101
+ }
102
+ ```
103
+
104
+ ### 2. 启动默认应用
105
+
106
+ `src/main.js`:
48
107
 
49
108
  ```js
50
109
  import 'element-plus/dist/index.css'
51
- import 'vant/lib/index.css'
52
110
  import 'vue3-django/style.css'
53
111
  import { createDjangoApp } from 'vue3-django'
54
112
  import apps from './apps.js'
@@ -57,218 +115,197 @@ const application = createDjangoApp({
57
115
  apps,
58
116
  title: '业务工作台',
59
117
  apiBaseURL: '/api/',
118
+ size: 'small',
60
119
  configModules: import.meta.glob('./views/**/config.js'),
61
120
  viewModules: import.meta.glob('./views/**/*.vue'),
62
121
  })
122
+
63
123
  application.mount('#app')
64
124
  ```
65
125
 
66
- 默认开启真实 API 认证,沿用 auth/user 登录、当前用户与退出接口;无认证的演示项目显式设置 `auth: false`。
67
- 首页默认进入 `/home/` 显示 Welcome,在默认布局与 tabs 内呈现;兼容 `/welcome/` 地址。可用 `homePath` 指定其他入口,通过 `components.home` 替换首页组件。
68
- 业务视图、关联视图与抽屉视图共享宿主提供的 viewModules,仍优先业务页面、缺失才回退模板。
69
-
70
- 可覆盖 `http`、`registry`、`auth`、`authOptions`、`router`、`history`、`locale`、`size`、`loginPath`、`menus`、`layoutProps`,
71
- 通过 `components: { app, layout, login, home }` 替换默认组件,`modelViews` 覆盖模型页面;`routes` 添加布局内业务路由。
72
- 返回的 `app`、`router`、`registry`、`auth` 可继续使用,挂载前可调用 `application.app.use(...)` 安装其他插件。
73
- `context` 可注入上传服务等已有扩展,不需要放弃底层 createDjango/createRegistry API。
126
+ 宿主自行配置开发代理或部署反向代理,让 /api/ 到达后端。默认开启认证;无认证项目可显式设置 `auth: false`。
74
127
 
75
- 默认排版提供 `--vd-body-margin`、`--vd-page-padding`、`--vd-text-color`、`--vd-background` CSS 变量。
76
- 右上角帐号区采用名称下拉菜单,退出登录需确认;修改密码默认指向 `/auth/change_password/`,未注册该页面时菜单项禁用。可通过 `layoutProps.changePasswordPath` 修改目标路径,单独使用 Layout 时也可用 `account` 插槽覆盖帐号区。
77
- 字体图标样式不自动引入:使用旧 Font Awesome 图标名称的宿主需自行安装并加载对应 CSS。
128
+ 默认使用中文,控件尺寸由 Element Plus 控制。框架不指定全局尺寸,上例与 dashboard 使用 small;也可选择 default / large。
78
129
 
79
- ## 当前功能
130
+ ### 3. 按需配置页面
80
131
 
81
- - `createDjangoApp`:内置默认入口、中文登录、布局和动态模型页,减少宿主框架代码。
82
- - `createDjangoRouter`、`genModelRouters` 与 `createAuth`:hash 路由、登录守卫和旧版认证接口。
83
- - `Layout`、`SideBar`、`ViewTabs`:菜单、独立编辑 path/tab、记录标题与图标。
84
- - `Form`:通用字段、校验、提交与字段错误;登录表单和 `ModelForm` 复用该组件。
85
- - `ModelTable`、`ModelForm`:元数据驱动列表与表单、新建抽屉、行双击编辑和行操作。
86
- - `Table`、`RemoteTable`、`useRemoteTable`:通用展示、远程请求与分页/排序状态,可用于非模型接口。
132
+ 不提供 config.js 时使用字段元数据生成默认视图。需要限制字段或定制操作时,在 `src/views/crm/customer/config.js` 中声明:
87
133
 
88
- 模型表根据 OPTIONS 的 `actions.SEARCH.ordering_fields` 自动启用远程排序。字段配置 `sortable: false` 可禁用,`sortable: true` 仅排序当前页,`sortable: 'custom'` 向后端发送 ordering(降序加 `-`)。改变排序回到第一页并保留搜索;清除排序时不再发送交互排序参数,恢复后端或固定查询的默认顺序。
89
-
90
- - `ModelSearch`、`ModelSelect`、`ModelRelations`:搜索占位符、模型选择与关联视图。
91
- - `Drawer`、`Actions`:动态内容、完成回调、按钮/更多菜单、确认与异步状态。
92
- - `TableWidget`:choices、布尔、数字、日期、外键、图片、视频、JSON、HTML 和自定义渲染。
134
+ ```js
135
+ export default {
136
+ list: {
137
+ items: ['id', 'name', 'is_active'],
138
+ pageSize: 20,
139
+ rowActions: [
140
+ {
141
+ name: 'inspect',
142
+ label: '查看',
143
+ icon: 'eye',
144
+ do: ({ row }) => console.log(row),
145
+ },
146
+ ],
147
+ },
148
+ form: {
149
+ items: ['name', { name: 'is_active', label: '启用' }],
150
+ },
151
+ }
152
+ ```
93
153
 
94
- 表格 datetime 默认沿用旧 Date2Now:近期显示“刚刚 / 几分钟前 / 几小时前 / 1天前”,较早记录显示简短日期,悬停显示完整本地时间。`date2now`、`timestamp` 控件使用相同规则;无时区 datetime 按旧项目的北京时间解析,显式时区保留。纯 date 保留 `YYYY-MM-DD`;需要其他格式时使用字段 formatter 覆盖。
154
+ 示例字段须与实际后端一致。items 中的对象可覆盖字段属性;create / update 配置优先于 form。
95
155
 
96
- - `ImageUpload`、`FileUpload`、`createUploadService`:平台无关的签名直传、后端确认、进度/取消/重试;需接入后端上传接口。
156
+ 默认生成 /crm/customer/ 列表、/crm/customer/add/ 新增和 /crm/customer/:id/ 编辑路由。列表新增通常使用抽屉,编辑打开独立标签页,标题优先采用记录的 `__str__`,其次为 title_field。
97
157
 
98
- 上传控件接入及后端协议见 [媒体上传](docs/uploads.md)。
158
+ ## 如何扩展
99
159
 
100
- ## 接入业务
160
+ - **字段**:使用 formatter、自定义 widget 或字段/列插槽。表格组件 widget 接收整行 value、field 与 context;表单 widget 使用 v-model。HTML 渲染经过 DOMPurify 清理。
161
+ - **页面**:在 views/app/model/list.vue 或 edit.vue 提供自己的组件。构建时通过 viewModules 发现,缺少时回退默认模型模板;已有页面加载失败不会被隐藏。
162
+ - **路由**:通过 createDjangoApp 的 routes 添加布局内业务页面,例如 `routes: [{ path: '/reports/', component: Reports, meta: { title: '报表' } }]`。自定义页面不会自动出现在模型菜单中,可通过 menus 定制导航。
163
+ - **布局**:通过 `components: { app, layout, login, home }` 替换默认组件。首页默认 /home/,显示 Welcome;homePath 可指定其他入口。修改密码需提供业务页面。
164
+ - **抽屉**:使用 `useDrawer().open({ component, context, onDone })`,内容组件 emit done 后关闭并回调。字符串路径需要 viewModules 中的对应业务组件,不会回退模型模板。
165
+ - **已有应用**:使用 createRegistry / createDjango 注入模型服务,单独挂载 ModelTable、ModelForm;此时编辑导航、认证和布局由宿主管理。
166
+ - **服务**:可传入自己的 http、registry、auth,并通过 context 注入上传服务。createDjangoApp 返回 app、router、registry、auth,挂载前可继续安装插件。
101
167
 
102
- ```js
103
- import { createApp } from 'vue'
104
- import ElementPlus from 'element-plus'
105
- import zhCn from 'element-plus/es/locale/lang/zh-cn'
106
- import { createHttp, createRegistry, createDjango } from 'vue3-django'
107
- import 'element-plus/dist/index.css'
108
- import 'vant/lib/index.css'
109
- import 'vue3-django/style.css'
110
- import App from './App.vue'
111
-
112
- const modules = import.meta.glob('/src/views/**/config.js')
113
- const registry = createRegistry({
114
- http: createHttp({ baseURL: '/api/' }),
115
- apps: { crm: { models: { customer: { verbose_name: '客户' } } } },
116
- loadViewsConfig: async (fullName) => {
117
- const load = modules[`/src/views/${fullName.replace('.', '/')}/config.js`]
118
- return load ? (await load()).default : {}
119
- },
120
- })
121
- createApp(App).use(ElementPlus, { locale: zhCn }).use(createDjango({ registry })).mount('#app')
122
- ```
168
+ 默认应用不引入字体图标 CSS。使用 Font Awesome 名称时需自行加载对应样式;也支持 emoji 和 Vue 图标组件。
123
169
 
124
- ```vue
125
- <script setup>
126
- import { ModelTable, ModelForm } from 'vue3-django'
127
- </script>
128
- <template>
129
- <ModelTable
130
- app-model="crm.customer"
131
- @edit="(row) => console.log(row)"
132
- />
133
- <ModelForm
134
- app-model="crm.customer"
135
- :id="123"
136
- @form-posted="console.log"
137
- />
138
- </template>
139
- ```
170
+ ### 操作与批处理
140
171
 
141
- `ModelForm` 不传 `id` 时新增,传入时编辑。ModelTable、ModelForm 及默认模型页固定使用 PC 控件,不提供 mobile 模式;通用 Table、RemoteTable、Form 仍保留移动能力。
142
- 表单支持 `v-model`、`defaults`、`items`,暴露 `load()`/`submit()`;列表暴露 `refresh()`。
143
- 保存事件 `form-posted` 保留 `{ model, data, intent }`。
172
+ 三种配置用途不同:
144
173
 
145
- 页面层提供 `ModelListView` 与 `ModelEditView`,demo 已使用这两个页面。
146
- `genModelRouters(apps, { modules: import.meta.glob('./views/**/*.vue') })` 通过 `import_or_use_template` 动态加载页面:优先使用宿主的 `views/<app>/<model>/list.vue`、`edit.vue`,不存在时使用内置模板;新增也复用 edit 模板。已有页面的加载错误不会回退。仍可传 `{ list, create, edit }` 覆盖页面组件。
147
- 默认 App 只渲染 RouterView,布局由父路由的内置 Root 提供,模型页作为子路由渲染。demo 直接使用内置组件,无需自己的 App.vue、Root.vue、Login.vue;需要定制时通过 components 覆盖。
148
- 列表页读取 `config.list` 并传递 `baseQueries` 等配置,处理编辑跳转及 revisions 刷新。
149
- `list.mode: 'grid'` 配合 `gridComponent` 接入宿主专用网格组件;未提供时仍使用 PC 表格(并非旧 ModelGrid 的全部功能)。
150
- 编辑页在 ModelForm 的 `bottom` 插槽挂载 `panels`(兼容旧拼写 `pannels`)折叠面板及 relations;面板的 component 接收 parent 模型上下文。
151
- 新增页在保存成功后替换为编辑路径,关联区只在已有主键时显示。内置页面需要安装 vue-router,页面 props 支持 appModel、id、mode。
174
+ | 配置 | 位置与用途 |
175
+ | --------------------------------------------------- | ------------------------------------ |
176
+ | apps.js 的模型 actions | 模型级操作,默认进入表头更多菜单 |
177
+ | apps.js 的模型 itemActions / config.list.rowActions | 单条记录操作 |
178
+ | config.list.batchActions | 列表上方的勾选批处理,自动显示选择列 |
152
179
 
153
- `ModelTable` 的“新增”默认打开 Drawer,宽度 66%。
154
- `createDefaults` 覆盖 `baseQueries` 的新建默认值,`createDrawerSize` 调整桌面宽度。
155
- 成功后关闭抽屉、发出 `created`(同 form-posted payload)并刷新列表;失败时保留表单。
156
- `create` 表示点击新增,旧宿主若通过该事件自行导航或打开弹窗,应设置 `create-mode="event"`。
157
- `edit` 仍由宿主处理;默认行双击也触发编辑,可用 `dblClickAction` 配置或空字符串禁用。
158
- 表格表头与行操作默认优先使用图标按钮,Tooltip 和 aria-label 保留操作名称;未配置 icon 的动作显示文字,更多菜单内仍保留文字。`actionIconOnly: false` 可恢复图标加文字,单个动作可设置 `iconOnly: false`。
159
- 桌面行操作采用紧凑按钮组,更多菜单仅显示箭头,沿用旧版 visibility 规则,仅在记录行悬停时显示;触屏保持常显。`hoverShow: false` 可关闭悬停隐藏,便于键盘操作。`actionsColumnWidth` 可指定操作列宽度(默认根据动作数量与文字估算)。模型表支持在 `list.options.remoteTable.table` 中配置这些选项。
180
+ 动作可使用函数 do、抽屉组件或 api。仅有 name、没有 do/api 的模型 actions 会导航到动作页面,需要宿主自行注册路由。前端 permission/show 控制展示,不能替代后端授权。
160
181
 
161
- 业务 `src/views/crm/customer/config.js` 保留旧式声明:
182
+ 批处理配置示例:
162
183
 
163
184
  ```js
164
185
  export default {
165
186
  list: {
166
- items: ['id', 'name', 'is_active'],
167
- options: {
168
- remoteTable: {
169
- rowActions: [
170
- { name: 'disable', label: '禁用', api: 'disable' },
171
- { name: 'inspect', label: '查看', do: ({ row, model }) => console.log(row, model) },
172
- ],
187
+ batchActions: [
188
+ {
189
+ name: 'disable',
190
+ label: '禁用',
191
+ api: 'batch_disable',
192
+ context: { enable_flag: false },
193
+ notice: '确定禁用这些记录吗?',
173
194
  },
174
- },
195
+ ],
175
196
  },
176
- form: { items: ['name', { name: 'is_active', label: '启用' }] },
177
197
  }
178
198
  ```
179
199
 
180
- 字段模板来自 OPTIONS,`items` 中的对象覆盖元数据。`create`/`update` 优先于 `form`。
181
- 自定义字段使用 `#field-name="{ field, value, update }"`,或将 Vue 组件传给字段 `widget`;
182
- 桌面和移动列表均支持 `#column-name="{ row, field }"`,插槽优先于内置字段渲染。
200
+ 未勾选时禁用,默认确认后执行。支持选中、全部、其余范围,接口提交 batch_action_ids、scope 和额外 context,查询参数携带当前筛选条件;后端负责按范围执行,成功后刷新并清除选择。
183
201
 
184
- 列表 `formatter(row, fieldName, fieldValue)` 保留 0、false 和空字符串返回值。
185
- 组件 widget 接收整行 value/modelValue、field 和 context;旧函数 widget 使用 `(row, field)` 返回 HTML。
186
- HTML 输出经过 DOMPurify 清理。`useFormWidget: true` 可在单元格使用表单字段控件,
187
- 变化发出 `field-change: { row, field, value }`,不会自动保存到 API。
202
+ ### 搜索、排序与字段展示
188
203
 
189
- `ModelRelations` 支持外键、多对多及通用关联配置、关联记录的新建抽屉,以及已有记录的添加/移出。
190
- 特殊上传控件及未覆盖的复杂业务仍由宿主插槽或自定义组件实现。
191
- 字段控件名称与关联配置示例见 [迁移指南](docs/migration.md)。
204
+ 搜索条件默认根据 OPTIONS 的 SEARCH 元数据生成,文本确认或失焦后查询,选择控件变化后查询;搜索过滤也需后端实现。
192
205
 
193
- ## 路由、布局与抽屉
206
+ ordering_fields 自动启用远程排序;字段 sortable 为 true 时仅排序当前页,为 custom 时发送 ordering,false 禁用。搜索和远程排序会回到第一页。
194
207
 
195
- 宿主通过 `genModelRouters(apps, { list, create, edit })` 指定页面组件,生成列表、`add/` 和 `:id/` 路由;
196
- 通过 `createDjangoRouter({ routes, auth })` 启用登录守卫,默认 hash 路由。
197
- 公开页面设置 `meta.loginRequired: false`;`meta.layout: 'main'` 页面在 Layout 中独立展示。
198
- demo 的编辑页每条记录使用独立 path/tab,标题优先采用记录 `__str__`,并支持模型 icon。
199
- 新建默认不打开 tab,已有 `add/` 页面路径继续兼容。
208
+ 表格支持 choices、布尔、数字、日期、外键、图片、视频、JSON 和自定义渲染。datetime 默认显示近期相对时间或简短日期,悬停显示完整时间;纯 date 显示 YYYY-MM-DD。
200
209
 
201
- `genMenusFromApps` 生成菜单,`Layout` 接入菜单、用户、退出事件及可选标题栏 actions。
202
- 图标支持 emoji、Vue 组件及 Font Awesome 类名;宿主使用字体图标时需自行加载相应样式。
210
+ ForeignKey 指向固定模型;GenericForeignKey 通过 content_type / object_id 映射目标,类型和 ID 字段名可配置。后者需要注册 contenttypes.contenttype 并提供 all/ 接口;未知类型或缺少路由时回退文本。控件自身的 error 事件目前不会由表格逐层转发。
203
211
 
204
- 布局子页面可使用 `useDrawer().open({ component, context, onDone })` 打开抽屉,
205
- 内容组件 emit `done(result)` 后关闭并回调。字符串组件需要宿主配置 `loadDrawerView`,
206
- 可用 `createDrawerViewLoader(import.meta.glob('./views/**/*.vue'))` 创建加载器。
207
- `Actions` 的函数 `do(context)` 执行业务操作,字符串/组件 `do` 打开抽屉;
208
- 嵌套数组项进入“更多”菜单。详细配置见 [迁移指南](docs/migration.md)。
212
+ ### Excel 导出
209
213
 
210
- ## DRF 约定
214
+ Table 默认提供 download 操作,导出传入的 rows;RemoteTable 和 ModelTable 按当前搜索、排序及固定查询条件获取全部记录,不只导出当前页。topActions 可覆盖或隐藏入口,保留 download 即可使用内置功能。
211
215
 
212
- - 模型标识 `app.model`;接口相对于 `baseURL` 为 `app/model/`,可通过模型 `url` 覆盖。
213
- - OPTIONS 读取 `actions.LIST` + `actions.POST`,编辑优先读取 `actions.PATCH`,回退 POST;仅标准 DRF OPTIONS 也可以运行。
214
- - 查询保留 `search`、`page`、`page_size` 和业务 `baseQueries`,数组以逗号序列化。首轮分页支持 `count/results`,不分页支持数组;暂不支持 cursor/limit-offset。
215
- - 新增 POST、编辑 PATCH,删除 DELETE;只读和未声明字段不提交。默认假设主键名 `id`,模型 `idField` 可定制列表行键,编辑页由宿主显式传入 `id`。
216
- - choice 保留真实 value 类型;布尔默认值延续旧版 true,可用 OPTIONS `default` 或 `defaults` 覆盖。
217
- - `400` 映射为字段错误和 `non_field_errors`,HTTP 错误保留 `code/msg`,网络错误 code=-1。
218
- - Session 认证保留 `csrftoken`/`X-CSRFToken`,后端需先设置 CSRF cookie;也支持 `http.setAuthToken(token)`,传空值清除 Token。
219
- - 跨域凭证、CORS 与 CSRF trusted origins 由业务环境配置;`createHttp` 可传 Axios 配置,不自动获取 CSRF cookie,也不持久保存 Token。
220
- - 可选 `createAuth` 沿用 `auth/user/login/`、`auth/user/current/`、`auth/user/logout/`;JWT 使用 `token.access` 和 Bearer 请求头,并通过 `access_token` cookie 保留 Token,也支持 Session。demo 登录页可记忆用户名,但不保存密码。
216
+ 导出文件为 .xlsx,文件名使用 title,模型表默认使用模型名称。按可见字段配置的顺序和 label 导出,保留原始数字、布尔和空值;支持 choices、formatter、嵌套 items、exportFormatter 和 export: false。不会执行单元格 widget 或导出 HTML 样式,文本不会自动转成公式。
217
+ excelGetAllData 可覆盖数据来源,excelFormat 可返回二维数组(首行为表头),excelWriter 可替换文件写入。ExcelJS 按需加载。
221
218
 
222
- ## 通用外键展示
219
+ 远程每次请求 maxPageSize 条(默认 1000),分页顺序下载并合并,超出该数量会先提醒数据一致性风险;导出不改变当前列表、分页或选择。支持取消,自定义 request(params, { signal }) 应传递 signal。分页不是后端快照,即使数量没变化也可能重复或漏行;大量记录或强一致性导出建议使用后端导出接口配合 createDownloadService。
223
220
 
224
- 普通 ForeignKey 指向固定模型;GenericForeignKey 使用记录的 content type 动态确定模型。
221
+ ## 应用状态管理
222
+
223
+ createDjangoApp 自动创建并安装应用级 store,通过返回值 application.store、组件中的 useDjangoStore() 或 $store 访问。不依赖 Vuex/Pinia,不共享全局单例,也不重复保存认证状态。
225
224
 
226
225
  ```js
227
- import { GenericForeignKey } from 'vue3-django'
228
-
229
- const ownerColumn = {
230
- name: 'owner_type',
231
- label: '属主',
232
- widget: GenericForeignKey,
233
- contentTypeIdField: 'owner_type',
234
- objectIdField: 'owner_id',
235
- }
226
+ import { useDjangoStore } from 'vue3-django'
227
+
228
+ // 在组件 setup 中调用。
229
+ const store = useDjangoStore()
230
+ const user = store.user // computed ref,与 auth.state.user 同步
231
+ const menus = store.menus // computed ref,按模型权限生成菜单
232
+
233
+ store.can('update', 'crm.customer')
234
+ store.invalidate('crm.customer') // 通知已打开的模型列表刷新
236
235
  ```
237
236
 
238
- 也可使用字符串 `widget: 'GenericForeignKey'`。默认字段为 `content_type`、`object_id`;兼容独立使用的平铺 context、表格 context.row 和整行 value。
239
- 需注册 `contenttypes.contenttype`,后端提供 `contenttypes/contenttype/all/` 接口(results 中包含 id、app_label、model)。映射在同一 registry 中缓存,支持分页,加载失败通过 error 事件反馈。
240
- 已注册模型且存在编辑路由时显示“模型名称:ID”链接,点击由 RouterLink 打开对应模型 tab;未知类型、缺少路由时显示文本,不生成无效链接。`labelField` 可指定未知类型的回退文本字段,`showLink: false` 可关闭跳转。
237
+ state 管理 apps、application(标题等配置)、party 和 revisions;user、ready、menus 为派生状态。登录、用户查询和退出分别调用 store.login / getUserInfo / logout,委托同一个 auth 实例。
238
+ 租户信息通过 store.getPartyInfo() 按需读取,默认接口 saas/party/current/;可通过 createDjangoApp 的 storeOptions.partyUrl 覆盖。切换用户或退出会清理租户与模型刷新状态。
239
+
240
+ store.subscribe((event, store) => ...) 订阅 user-ready、user-logout、party-ready、model-changed,返回取消订阅函数。应用卸载会清理订阅和监听;组件自行订阅时应在卸载时取消。
241
+ 自组装应用可使用 createDjangoStore({ auth, http, apps, application }) 并 app.use(store),同时将 store 和 store.state.revisions 传入 createDjango 的 context。默认入口也可传入 store,但须与应用使用同一个 auth 实例。
242
+ 这里不提供旧 Vuex commit/dispatch 或 Vue 2 事件总线接口;用户数据持久化、业务日志和额外业务状态由宿主按需实现。
241
243
 
242
- ## 模型批量操作
244
+ ## 基础工具与服务
243
245
 
244
- 在模型 `config.js` 的 `list.batchActions` 中配置,或向 ModelTable 传入 `batch-actions`;配置后自动显示勾选列。
246
+ 工具按职责拆分:utils 为无框架状态的函数,core 服务显式注入依赖,browser 工具仅在调用时访问 DOM。不会修改 Array.prototype,也不会在导入时发起请求或读写 localStorage。
245
247
 
246
248
  ```js
247
- export default {
248
- list: {
249
- batchActions: [
250
- {
251
- name: 'disable',
252
- label: '禁用',
253
- icon: 'ban',
254
- api: 'batch_disable',
255
- context: { enable_flag: false },
256
- notice: '确定禁用这些记录吗?',
257
- },
258
- ],
249
+ import { createStorage, createDownloadService, mapConcurrent, arrayDelta } from 'vue3-django'
250
+
251
+ const cache = createStorage({ namespace: 'dashboard', tenantId: 'school-a', userId: 12 })
252
+ cache.set('table-settings', { pageSize: 20 }, { ttl: 86400000 })
253
+ const settings = cache.get('table-settings', { pageSize: 10 })
254
+
255
+ const downloads = createDownloadService({ http: application.registry.http })
256
+ await downloads.download('crm/customer/export/', { params: { is_active: true } })
257
+
258
+ const controller = new AbortController()
259
+ const results = await mapConcurrent(
260
+ [1, 2, 3],
261
+ 2,
262
+ async (id, index, signal) => {
263
+ const response = await application.registry.http.get(`crm/customer/${id}/`, { signal })
264
+ return response.data
259
265
  },
260
- }
266
+ { signal: controller.signal },
267
+ )
268
+
269
+ const changes = arrayDelta([1, 2], [2, 3]) // { added: [3], removed: [1] }
261
270
  ```
262
271
 
263
- 沿用 vue-django 的 `select`(选中)、`all`(全部)、`exclude`(其余)范围。默认 POST 到模型列表的动作接口,请求体包含 `batch_action_ids`、`scope` 和 action.context,查询参数携带当前搜索与固定条件;后端负责按范围处理数据。
264
- 未选中时禁用,默认弹出确认(`confirm: false` 可关闭);接口动作默认检查 `api || name` 权限,执行成功刷新列表并清除选择。
265
- 自定义 `do({ selection, count, scope, model, parent, queries, table, confirmResult })` 和抽屉组件动作复用现有 Actions;函数确认的返回值通过 confirmResult 传递。
272
+ - createStorage 支持 JSON 值、毫秒 TTL 和可注入的 Storage 后端;保留 0/false/空字符串,损坏或过期时返回 fallback。set/remove/clear 返回是否成功,onError 接收错误。clear 只删除当前应用/租户/用户命名空间;配额失败不清空数据。不用于保存密码等敏感数据,切换用户时应创建对应用户的新实例。
273
+ - createDownloadService 使用注入的 HTTP 客户端,支持筛选、AbortSignal 和 Content-Disposition 文件名;默认拒绝绝对外部地址,避免意外发送应用认证信息。外部公开下载使用独立无认证客户端并显式设置 allowExternal。save 可替换,saveBlob 可单独使用。
274
+ - mapConcurrent 验证正整数并发数,按输入顺序返回结果,不修改输入。默认失败后停止调度,等待已启动任务结束后抛错;settled: true 收集每项结果。取消立即拒绝并停止后续调度,运行中的任务需自行使用传入的 signal。onProgress 接收 completed/total/index。
275
+ - arrayEquals 比较嵌套数组(对象按引用比较);arrayDelta 按值/引用返回去重后的 added/removed,不跟踪对象内部变化。
276
+
277
+ 批量导入、任务 SSE 监听、学习时长和业务日志不包含在这套基础工具中,按业务需求另行接入。
278
+
279
+ ## 后端需要满足什么约定?
280
+
281
+ 默认模型接口相对于 apiBaseURL 为 app/model/,可用模型 url 覆盖。主键默认 id,可通过 idField 定制。
282
+
283
+ | 请求 | 约定 |
284
+ | --------------------- | --------------------------------------------------- |
285
+ | OPTIONS app/model/ | actions.POST 字段元数据;可扩展 LIST、PATCH、SEARCH |
286
+ | GET app/model/ | 分页返回 count/results,或返回不分页数组 |
287
+ | GET app/model/:id/ | 返回记录对象 |
288
+ | POST app/model/ | 新增,返回保存后的记录 |
289
+ | PATCH app/model/:id/ | 更新,返回保存后的记录 |
290
+ | DELETE app/model/:id/ | 删除 |
291
+ | HTTP 400 | 字段错误对象,支持 non_field_errors |
292
+
293
+ 仅标准 DRF OPTIONS 可用于基础表单与列表;搜索字段、过滤字段和排序候选依赖扩展 SEARCH 元数据或显式前端配置。查询使用 search、page、page_size、ordering;数组以逗号序列化,需后端支持对应过滤方式。当前不支持 cursor / limit-offset 分页。
294
+
295
+ 默认认证接口是 auth/user/login/(POST)、auth/user/current/(GET)、auth/user/logout/(GET),不是 DRF 自动提供的接口,可用 authOptions.endpoints 或自定义 auth 替换。支持 Session 与登录响应 token.access 的 Bearer 认证;后者保留在 access_token cookie 中,不自动刷新 token。
296
+
297
+ Session 的 CSRF cookie 由后端设置;跨域凭证、CORS 与 CSRF trusted origins 由业务环境配置。前端不会替代后端权限、字段和批处理校验。
298
+
299
+ ## 当前边界
266
300
 
267
- ## 尚未覆盖
301
+ - 特殊业务页面、后端导出和动作接口由宿主实现;框架提供前端表格 Excel 导出,但不自动生成后端业务逻辑。
302
+ - 复杂分组列、完整 ModelGrid 和历史 dialog 动作配置尚未实现。
303
+ - 上传已实现前端初始化、单次 PUT/POST 直传与后端确认协议;业务需提供签名、确认和对象存储。分片、断点续传、视频转码/VOD 尚未实现。
304
+ - 无效或未注册的关联不能自动补成可用业务模型。
305
+ - 自动化测试通过不等于真实后端、云存储和所有浏览器交互已经验收。
268
306
 
269
- 这仍是渐进迁移版本,不是旧组件的完整替代。旧业务动作页面及批量操作的旧式 dialog 配置尚未完整迁移;
270
- 基础图片/文件上传已实现,云存储签名及确认接口、大文件分片/断点续传、视频转码/VOD 尚未接通;
271
- 复杂分组列(subColumns/rows/headerWidget)尚未支持;Date2Now 当前显示完整本地时间,未实现旧版相对时间文案。
272
- 测试与构建通过不等于所有真实后端模型、权限和业务动作已经验收。
307
+ ## 进一步阅读
273
308
 
274
- 历史迭代范围见 [迭代计划](docs/iterations.md),当前接入约定与兼容差异见 [迁移指南](docs/migration.md)。
309
+ - [媒体上传](docs/uploads.md):对象存储协议、上传服务和控件接入。
310
+ - [迁移指南与配置参考](docs/migration.md):详细控件、关联、动作与兼容约定;已有 vue-django 项目可从这里了解迁移差异。
311
+ - [迭代记录](docs/iterations.md):开发过程与历史范围,不作为当前能力清单。
@@ -1 +1 @@
1
- .vd-toolbar{display:flex;align-items:center;gap:8px;margin:0 0 8px}.vd-toolbar h2{flex:1;margin:0}.vd-toolbar .el-input{max-width:360px}.vd-card,.el-alert{margin-bottom:16px}body:has(.vd-app){margin:var(--vd-body-margin, 0)}.vd-app[v-cloak]{display:none}.vd-app{color:var(--vd-text-color, #253447);background:var(--vd-background, white);font-family:system-ui,-apple-system,sans-serif}.vd-app .vd-model-page{width:100%;box-sizing:border-box;padding:var(--vd-page-padding, 0 4px 12px)}.model-icon[data-v-5a104f44]{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:1.25em;height:1.25em;margin-right:8px;vertical-align:middle}.viewtabs[data-v-5703e450]{margin:0 8px}.viewtabs[data-v-5703e450] .el-tabs__header{margin-bottom:10px}.tab-icon[data-v-5703e450]{width:1em;height:1em;margin-right:6px}.vd-actions[data-v-2e52c126]{display:inline-flex;align-items:center;white-space:nowrap;vertical-align:middle}.vd-actions.is-compact[data-v-2e52c126] .el-button+.el-button{margin-left:0}.vd-actions.is-compact[data-v-2e52c126] .el-dropdown{margin-left:4px}.vd-actions-more[data-v-2e52c126]{border:0;background:transparent}.vd-actions[data-v-2e52c126] .vd-action-icon .model-icon{margin-right:0}.django-layout[data-v-3bbd5166]{--vd-header-height: var(--el-component-size);display:flex;flex-direction:column;min-height:100vh;background:#fff;color:#253447}.layout-header[data-v-3bbd5166]{min-height:var(--vd-header-height);padding:0 16px;display:flex;align-items:center;gap:12px;background:#fff;border-bottom:1px solid #e5eaf0}.layout-brand[data-v-3bbd5166]{font-weight:700;color:inherit;text-decoration:none}.layout-account[data-v-3bbd5166]{margin-left:auto;display:flex;align-items:center;gap:12px;min-width:0}.account-trigger[data-v-3bbd5166]{display:inline-flex;align-items:center;gap:10px;max-width:min(240px,40vw)}.account-trigger[data-v-3bbd5166]:focus-visible{outline:2px solid var(--el-color-primary, #409eff);outline-offset:-2px}.account-name[data-v-3bbd5166]{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account-arrow[data-v-3bbd5166]{flex-shrink:0;color:var(--el-text-color-placeholder, #a8abb2)}.layout-body[data-v-3bbd5166]{display:flex;flex:1}.layout-sidebar[data-v-3bbd5166]{width:190px;flex-shrink:0;background:#fff;border-right:1px solid #e5eaf0}.layout-sidebar[data-v-3bbd5166] .el-menu{border-right:0}.layout-content[data-v-3bbd5166]{min-width:0;flex:1;padding-top:0}.menu-toggle[data-v-3bbd5166],.menu-backdrop[data-v-3bbd5166]{display:none}.layout-content[data-v-3bbd5166] .viewtabs{margin:0 8px}@media(max-width:768px){.menu-toggle[data-v-3bbd5166]{display:inline-flex}.layout-header[data-v-3bbd5166]{padding:0 12px}.layout-sidebar[data-v-3bbd5166]{display:none}.layout-sidebar.opened[data-v-3bbd5166]{display:block;position:fixed;top:calc(var(--vd-header-height) + 1px);bottom:0;left:0;z-index:21;overflow:auto}.menu-backdrop[data-v-3bbd5166]{display:block;position:fixed;inset:calc(var(--vd-header-height) + 1px) 0 0;background:#0006;border:0;z-index:20}.layout-content[data-v-3bbd5166] .viewtabs{margin:0 8px}}.vd-upload[data-v-777ec908]{display:flex;flex-wrap:wrap;gap:8px}.vd-upload-item[data-v-777ec908]{display:flex;align-items:center;flex-wrap:wrap;gap:6px;max-width:100%}.vd-upload-item img[data-v-777ec908]{width:72px;height:72px;object-fit:contain}.vd-upload-item a[data-v-777ec908]{overflow-wrap:anywhere}.vd-upload button[data-v-777ec908]{cursor:pointer;border:1px solid #dcdfe6;border-radius:4px;background:#fff;padding:5px 10px;color:#409eff}.vd-cover-placeholder[data-v-52989853]{display:inline-flex;align-items:center;justify-content:center;min-height:32px;color:#909399;font-size:12px}.vd-login[data-v-a32e9ae3]{max-width:400px;margin:80px auto;padding:24px}h1[data-v-a32e9ae3]{font-size:24px}.vd-home[data-v-e81cec03]{padding:var(--vd-home-padding, 24px)}h1[data-v-e81cec03]{margin:0 0 12px;font-size:24px;font-weight:500}p[data-v-e81cec03]{margin:0;color:var(--el-text-color-secondary, #909399)}.vd-batch-actions[data-v-bf15a101]{display:flex;align-items:center;gap:6px;margin:4px 0}.vd-model-search[data-v-d6cb8ac9]{display:flex;flex-wrap:wrap;align-items:flex-start;gap:4px;margin-bottom:4px}.vd-search-field[data-v-d6cb8ac9]{flex:0 1 var(--search-field-width, 8rem);width:var(--search-field-width, 8rem);min-width:0;max-width:100%}.vd-search-field[data-v-d6cb8ac9] .el-input,.vd-search-field[data-v-d6cb8ac9] .el-select,.vd-search-field[data-v-d6cb8ac9] .el-input-number,.vd-search-field[data-v-d6cb8ac9] .el-date-editor{width:100%;min-width:0}.vd-search-range[data-v-d6cb8ac9]{display:flex;align-items:center;gap:4px}.foreignkey-link[data-v-72c5023a]{color:var(--el-color-primary, #409eff);text-decoration:none}.foreignkey-link[data-v-72c5023a]:hover{text-decoration:underline}.foreignkey-link[data-v-75351597]{color:var(--el-color-primary, #409eff);text-decoration:none}.foreignkey-link[data-v-75351597]:hover{text-decoration:underline}.vd-table-text[data-v-286ae7d1]{white-space:pre-wrap;overflow-wrap:anywhere}.vd-table-boolean.is-true[data-v-286ae7d1]{color:var(--el-color-success, green);font-weight:700}.vd-table-images[data-v-286ae7d1]{display:flex;flex-wrap:wrap;gap:4px}.vd-table-video[data-v-286ae7d1]{width:180px;max-width:100%}.vd-table-json[data-v-286ae7d1]{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.vd-table-json dd[data-v-286ae7d1]{margin:0}@media(hover:hover)and (pointer:fine){[data-v-415a1a29] .vd-row-actions.hover-show{visibility:hidden}[data-v-415a1a29] .el-table__row:hover .vd-row-actions.hover-show{visibility:visible;cursor:pointer}}.vd-table-actions[data-v-415a1a29]{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-bottom:8px}.vd-table-pagination[data-v-560aef57]{padding:10px 0 6px}.vd-table-pagination[data-v-560aef57] .el-pagination{display:flex;flex-wrap:wrap;justify-content:flex-start;row-gap:8px}.vd-table-pagination.is-mobile[data-v-560aef57]{padding:12px 0}@media(max-width:600px){.vd-table-pagination[data-v-560aef57] .el-pagination__jump{margin-left:0}}
1
+ .vd-toolbar{display:flex;align-items:center;gap:8px;margin:0 0 8px}.vd-toolbar h2{flex:1;margin:0}.vd-toolbar .el-input{max-width:360px}.vd-card,.el-alert{margin-bottom:16px}body:has(.vd-app){margin:var(--vd-body-margin, 0)}.vd-app[v-cloak]{display:none}.vd-app{color:var(--vd-text-color, #253447);background:var(--vd-background, white);font-family:system-ui,-apple-system,sans-serif}.vd-app .vd-model-page{width:100%;box-sizing:border-box;padding:var(--vd-page-padding, 0 4px 12px)}.model-icon[data-v-5a104f44]{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:1.25em;height:1.25em;margin-right:8px;vertical-align:middle}.viewtabs[data-v-5703e450]{margin:0 8px}.viewtabs[data-v-5703e450] .el-tabs__header{margin-bottom:10px}.tab-icon[data-v-5703e450]{width:1em;height:1em;margin-right:6px}.vd-actions[data-v-2e52c126]{display:inline-flex;align-items:center;white-space:nowrap;vertical-align:middle}.vd-actions.is-compact[data-v-2e52c126] .el-button+.el-button{margin-left:0}.vd-actions.is-compact[data-v-2e52c126] .el-dropdown{margin-left:4px}.vd-actions-more[data-v-2e52c126]{border:0;background:transparent}.vd-actions[data-v-2e52c126] .vd-action-icon .model-icon{margin-right:0}.django-layout[data-v-3bbd5166]{--vd-header-height: var(--el-component-size);display:flex;flex-direction:column;min-height:100vh;background:#fff;color:#253447}.layout-header[data-v-3bbd5166]{min-height:var(--vd-header-height);padding:0 16px;display:flex;align-items:center;gap:12px;background:#fff;border-bottom:1px solid #e5eaf0}.layout-brand[data-v-3bbd5166]{font-weight:700;color:inherit;text-decoration:none}.layout-account[data-v-3bbd5166]{margin-left:auto;display:flex;align-items:center;gap:12px;min-width:0}.account-trigger[data-v-3bbd5166]{display:inline-flex;align-items:center;gap:10px;max-width:min(240px,40vw)}.account-trigger[data-v-3bbd5166]:focus-visible{outline:2px solid var(--el-color-primary, #409eff);outline-offset:-2px}.account-name[data-v-3bbd5166]{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account-arrow[data-v-3bbd5166]{flex-shrink:0;color:var(--el-text-color-placeholder, #a8abb2)}.layout-body[data-v-3bbd5166]{display:flex;flex:1}.layout-sidebar[data-v-3bbd5166]{width:190px;flex-shrink:0;background:#fff;border-right:1px solid #e5eaf0}.layout-sidebar[data-v-3bbd5166] .el-menu{border-right:0}.layout-content[data-v-3bbd5166]{min-width:0;flex:1;padding-top:0}.menu-toggle[data-v-3bbd5166],.menu-backdrop[data-v-3bbd5166]{display:none}.layout-content[data-v-3bbd5166] .viewtabs{margin:0 8px}@media(max-width:768px){.menu-toggle[data-v-3bbd5166]{display:inline-flex}.layout-header[data-v-3bbd5166]{padding:0 12px}.layout-sidebar[data-v-3bbd5166]{display:none}.layout-sidebar.opened[data-v-3bbd5166]{display:block;position:fixed;top:calc(var(--vd-header-height) + 1px);bottom:0;left:0;z-index:21;overflow:auto}.menu-backdrop[data-v-3bbd5166]{display:block;position:fixed;inset:calc(var(--vd-header-height) + 1px) 0 0;background:#0006;border:0;z-index:20}.layout-content[data-v-3bbd5166] .viewtabs{margin:0 8px}}.vd-upload[data-v-777ec908]{display:flex;flex-wrap:wrap;gap:8px}.vd-upload-item[data-v-777ec908]{display:flex;align-items:center;flex-wrap:wrap;gap:6px;max-width:100%}.vd-upload-item img[data-v-777ec908]{width:72px;height:72px;object-fit:contain}.vd-upload-item a[data-v-777ec908]{overflow-wrap:anywhere}.vd-upload button[data-v-777ec908]{cursor:pointer;border:1px solid #dcdfe6;border-radius:4px;background:#fff;padding:5px 10px;color:#409eff}.vd-cover-placeholder[data-v-52989853]{display:inline-flex;align-items:center;justify-content:center;min-height:32px;color:#909399;font-size:12px}.vd-login[data-v-a32e9ae3]{max-width:400px;margin:80px auto;padding:24px}h1[data-v-a32e9ae3]{font-size:24px}.vd-home[data-v-e81cec03]{padding:var(--vd-home-padding, 24px)}h1[data-v-e81cec03]{margin:0 0 12px;font-size:24px;font-weight:500}p[data-v-e81cec03]{margin:0;color:var(--el-text-color-secondary, #909399)}.vd-batch-actions[data-v-bf15a101]{display:flex;align-items:center;gap:6px;margin:4px 0}.vd-model-search[data-v-d6cb8ac9]{display:flex;flex-wrap:wrap;align-items:flex-start;gap:4px;margin-bottom:4px}.vd-search-field[data-v-d6cb8ac9]{flex:0 1 var(--search-field-width, 8rem);width:var(--search-field-width, 8rem);min-width:0;max-width:100%}.vd-search-field[data-v-d6cb8ac9] .el-input,.vd-search-field[data-v-d6cb8ac9] .el-select,.vd-search-field[data-v-d6cb8ac9] .el-input-number,.vd-search-field[data-v-d6cb8ac9] .el-date-editor{width:100%;min-width:0}.vd-search-range[data-v-d6cb8ac9]{display:flex;align-items:center;gap:4px}.foreignkey-link[data-v-72c5023a]{color:var(--el-color-primary, #409eff);text-decoration:none}.foreignkey-link[data-v-72c5023a]:hover{text-decoration:underline}.foreignkey-link[data-v-75351597]{color:var(--el-color-primary, #409eff);text-decoration:none}.foreignkey-link[data-v-75351597]:hover{text-decoration:underline}.vd-table-text[data-v-286ae7d1]{white-space:pre-wrap;overflow-wrap:anywhere}.vd-table-boolean.is-true[data-v-286ae7d1]{color:var(--el-color-success, green);font-weight:700}.vd-table-images[data-v-286ae7d1]{display:flex;flex-wrap:wrap;gap:4px}.vd-table-video[data-v-286ae7d1]{width:180px;max-width:100%}.vd-table-json[data-v-286ae7d1]{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.vd-table-json dd[data-v-286ae7d1]{margin:0}@media(hover:hover)and (pointer:fine){[data-v-1a20346e] .vd-row-actions.hover-show{visibility:hidden}[data-v-1a20346e] .el-table__row:hover .vd-row-actions.hover-show{visibility:visible;cursor:pointer}}.vd-table-actions[data-v-1a20346e]{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-bottom:8px}.vd-table-pagination[data-v-aa3ecf07]{padding:10px 0 6px}.vd-table-pagination[data-v-aa3ecf07] .el-pagination{display:flex;flex-wrap:wrap;justify-content:flex-start;row-gap:8px}.vd-table-pagination.is-mobile[data-v-aa3ecf07]{padding:12px 0}@media(max-width:600px){.vd-table-pagination[data-v-aa3ecf07] .el-pagination__jump{margin-left:0}}