优化更新

This commit is contained in:
2026-09-13 21:53:16 +08:00
parent 8548fe9ea1
commit 3f8626fb10
84 changed files with 2058 additions and 2594 deletions
@@ -15,8 +15,8 @@ Flask 使用 `Blueprint` 机制实现模块化路由注册。每个业务模块
from flask import Blueprint, request
from src.core.permission import check_permission
from src.core.response import R
from src.common.utils.request import parse_request_body, parse_id_list
from src.core import response as R
from src.common.utils.request import parse_request_body, parse_batch_ids
# 定义 Blueprint
# 参数说明:
@@ -14,7 +14,8 @@
岗位模型定义,包含名称、排序、状态等属性。
"""
from sqlalchemy import Column, String, Integer, text
from sqlalchemy import text
from src.core.database import db
from src.common.base_db import BaseDB
from src.common.base_model import BaseModel
@@ -36,11 +37,11 @@ class Position(BaseModel, BaseDB):
# 字段定义
# ============================================================
# 岗位名称
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
# 岗位状态:1-在用 2-停用
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
name = db.Column(db.String(255), nullable=False, index=True, comment="岗位名称")
# 岗位状态:0-停用 1-启用
status = db.Column(db.Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:0-停用 1-启用")
# 岗位排序
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
sort = db.Column(db.Integer, default=0, server_default=text('0'), comment="岗位排序")
# ============================================================
# 内置方法
+1 -1
View File
@@ -56,7 +56,7 @@ flask db upgrade
### Q6: 批量删除报 500 错误
**原因**:`parse_id_list` 函数未正确导入或使用。
**原因**:`parse_batch_ids` 函数未正确导入或使用。
**解决**:确保正确导入并使用:
+134 -152
View File
@@ -1,6 +1,6 @@
# 前端页面视图
前端页面视图是用户直接交互的界面。项目使用 Vue3 + ElementPlus + Vite 构建。一个完整的 CRUD 页面由 4 个文件组成:
前端页面视图是用户直接交互的界面。项目使用 Vue3 + AntDesign Vue + Vite 构建。一个完整的 CRUD 页面由 4 个文件组成:
```
ui/src/views/system/position/
@@ -19,43 +19,51 @@ ui/src/views/system/position/
```vue
<template>
<PageWrapper>
<el-card :bordered="false" class="pt-3 mb-3 proCard">
<a-card :bordered="false" class="pt-3 mb-3 proCard">
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
</el-card>
<el-card :bordered="false" class="proCard">
</a-card>
<a-card :bordered="false" class="proCard">
<BasicTable
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
ref="tableRef"
:actionColumn="actionColumn"
@selection-change="onSelectionChange"
scroll-x="1200"
:row-selection="{ onChange: onSelectionChange }"
>
<template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<template #icon>
<el-icon class="el-input__icon">
<PlusOutlined />
</el-icon>
</template>
添加岗位
</el-button>
<el-button
type="danger"
@click="handleDelete()"
:disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']"
>
<template #icon>
<el-icon class="el-input__icon">
<Delete />
</el-icon>
</template>
删除
</el-button>
<a-space>
<a-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<template #icon><PlusOutlined /></template>
新建
</a-button>
<a-button
type="danger"
@click="handleDelete()"
:disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']"
>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" @click="handleEdit(record.id)" v-perm="['sys:position:update']">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="primary" danger @click="handleDelete(record.id)" v-perm="['sys:position:delete']">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
</BasicTable>
</el-card>
</a-card>
<editDialog
v-if="editVisible"
@@ -67,15 +75,14 @@ ui/src/views/system/position/
</template>
<script lang="ts" setup>
import { reactive, ref, h, nextTick, defineAsyncComponent } from 'vue';
import { ColProps } from 'element-plus';
import { reactive, ref, nextTick, defineAsyncComponent } from 'vue';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { schemas } from './querySchemas';
import { useForm } from '@/components/Form/index';
import { TableAction } from '@/components/Table';
import { getPositionList, positionDelete, positionBatchDelete } from '@/api/system/position';
import { columns } from './columns';
import { PlusOutlined } from '@vicons/antd';
import { message, confirm } from '@/utils/auth';
import { Modal, message } from 'ant-design-vue';
const editDialog = defineAsyncComponent(() => import('./edit.vue'));
const positionId = ref(0);
const editVisible = ref(false);
@@ -90,41 +97,10 @@ ui/src/views/system/position/
status: '',
});
/**
* 定义操作栏
*/
const actionColumn = reactive({
width: 200,
label: '操作',
prop: 'action',
fixed: 'right',
render(record) {
return h(TableAction, {
style: 'button',
actions: [
{
label: '编辑',
icon: 'Edit',
type: 'warning',
onClick: handleEdit.bind(null, record),
auth: ['sys:position:update'],
},
{
label: '删除',
icon: 'Delete',
type: 'danger',
onClick: handleDelete.bind(null, record),
auth: ['sys:position:delete'],
},
],
});
},
});
/**
* 加载数据列表
*/
const loadDataTable = async (res: any) => {
const loadDataTable = async (res) => {
const result = await getPositionList({ ...formParams, ...res });
return result;
};
@@ -140,18 +116,25 @@ ui/src/views/system/position/
* 注册搜索表单
*/
const [register, {}] = useForm({
labelWidth: 80,
layout: 'horizontal',
colProps: { span: 6 } as ColProps,
submitOnReset: true,
rowProps: { gutter: [16, 0] },
colProps: {
xs: 24,
sm: 24,
md: 12,
lg: 8,
xl: 6,
},
labelCol: { span: 6, offset: 0 },
schemas,
});
/**
* 执行提交表单
*/
function handleSubmit(values: Recordable) {
handleReset();
function handleSubmit(values) {
for (const key in formParams) {
formParams[key] = '';
}
for (const key in values) {
formParams[key] = values[key];
}
@@ -165,6 +148,7 @@ ui/src/views/system/position/
for (const key in formParams) {
formParams[key] = '';
}
reloadTable();
}
/**
@@ -179,24 +163,25 @@ ui/src/views/system/position/
/**
* 执行编辑
*/
const handleEdit = async (record: Recordable) => {
positionId.value = record.row.id;
async function handleEdit(id) {
positionId.value = id;
await nextTick();
editVisible.value = true;
};
}
/**
* 执行删除(单条 + 批量)
*/
async function handleDelete(record: Recordable) {
let ids = [];
if (!record) {
ids = selectionData.value.map(({ id }) => id);
}
await confirm('确定要删除?');
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids);
message('删除成功');
reloadTable();
async function handleDelete(id) {
Modal.confirm({
title: '提示',
content: '确定要删除?',
onOk: async () => {
id ? await positionDelete(id) : await positionBatchDelete(selectionData.value);
message.success('删除成功');
reloadTable();
},
});
}
/**
@@ -210,17 +195,17 @@ ui/src/views/system/position/
### 代码解析
**页面结构**:`PageWrapper` 包裹两个 `el-card`,上方是搜索表单,下方是数据表格。
**页面结构**:`PageWrapper` 包裹两个 `a-card`,上方是搜索表单,下方是数据表格。
**搜索表单**:通过 `useForm` 注册,Schema 从 `querySchemas.ts` 导入。`submitOnReset: true` 表示重置时自动触发查询。
**搜索表单**:通过 `useForm` 注册,Schema 从 `querySchemas.ts` 导入。
**数据表格**:通过 `BasicTable` 组件渲染,`request` 属性绑定数据加载函数,`columns` 从 `columns.ts` 导入。
**操作栏**:通过 `actionColumn` 的 `render` 函数使用 `h()` 渲染 `TableAction`,支持权限控制(`auth`)。
**操作列**:通过 `bodyCell` 插槽实现,配合 `v-perm` 指令控制权限。
**编辑弹窗**:使用 `defineAsyncComponent` 懒加载 `edit.vue`,通过 `v-model:visible` 控制显示,`positionId` 传入编辑记录 ID(0 表示新增)。
**删除逻辑**:单条删除传入 `record`,批量删除从 `selectionData` 取已选 ID。共用同一个 `handleDelete` 方法。
**删除逻辑**:使用 `Modal.confirm` 确认弹窗,单条删除传入 `id`,批量删除从 `selectionData` 取已选 ID。
## 2. 编辑弹窗 `edit.vue`
@@ -230,45 +215,44 @@ ui/src/views/system/position/
```vue
<template>
<el-dialog
v-model="props.visible"
<a-modal
v-model:visible="props.visible"
:title="props.positionId ? '编辑' : '新增'"
width="500"
:close-on-click-modal="false"
:before-close="dialogClose"
width="500px"
@cancel="dialogClose"
>
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px">
<el-form-item
<a-form class="ls-form" ref="formRef" :model="formData" :label-col="{ style: { width: '85px' } }">
<a-form-item
label="岗位名称"
prop="name"
name="name"
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
>
<el-input class="ls-input" v-model="formData.name" placeholder="请输入岗位名称" clearable />
</el-form-item>
<el-form-item label="岗位状态" prop="status">
<el-radio-group v-model="formData.status" name="status">
<el-radio :value="1">正常</el-radio>
<el-radio :value="2">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" />
</el-form-item>
</el-form>
<a-input v-model:value="formData.name" placeholder="请输入岗位名称" allow-clear />
</a-form-item>
<a-form-item label="岗位状态" name="status">
<a-radio-group v-model:value="formData.status" name="status">
<a-radio :value="1">正常</a-radio>
<a-radio :value="2">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="formData.sort" />
</a-form-item>
</a-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogClose">取消</el-button>
<el-button :loading="subLoading" type="primary" @click="submit"> 确定 </el-button>
<a-button @click="dialogClose">取消</a-button>
<a-button :loading="subLoading" type="primary" @click="submit"> 确定 </a-button>
</span>
</template>
</el-dialog>
</a-modal>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus';
import type { FormInstance } from 'ant-design-vue';
import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position';
import { onMounted, reactive, shallowRef } from 'vue';
import { message } from '@/utils/auth';
import { message } from 'ant-design-vue';
import { useLockFn } from '@/utils/useLockFn';
const emit = defineEmits(['success', 'update:visible']);
@@ -306,7 +290,7 @@ ui/src/views/system/position/
const handleSubmit = async () => {
await formRef.value?.validate();
props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
message('操作成功');
message.success('操作成功');
emit('update:visible', false);
emit('success');
};
@@ -369,68 +353,66 @@ ui/src/views/system/position/
```typescript
import { h } from 'vue';
import { ElTag } from 'element-plus';
import { Tag } from 'ant-design-vue';
export const columns = [
{
type: 'selection',
},
{
label: 'ID',
prop: 'id',
title: 'ID',
dataIndex: 'id',
fixed: 'left',
width: 50,
},
{
label: '岗位名称',
prop: 'name',
minWidth: 100,
title: '岗位名称',
dataIndex: 'name',
width: 100,
},
{
label: '岗位状态',
prop: 'status',
minWidth: 100,
render(record) {
title: '岗位状态',
dataIndex: 'status',
width: 100,
customRender({ record }) {
return h(
ElTag,
{
type: record.row.status == 1 ? 'success' : 'danger',
},
{
default: () => (record.row.status == 1 ? '正常' : '停用'),
},
Tag,
{ color: record.status == 1 ? 'success' : 'error' },
{ default: () => (record.status == 1 ? '正常' : '停用') },
);
},
},
{
label: '排序',
prop: 'sort',
minWidth: 100,
title: '排序',
dataIndex: 'sort',
width: 100,
},
{
label: '创建人',
prop: 'createUser',
minWidth: 100,
title: '创建人',
dataIndex: 'createUser',
width: 100,
},
{
label: '创建时间',
prop: 'createTime',
title: '创建时间',
dataIndex: 'createTime',
width: 180,
},
{
title: '操作',
fixed: 'right',
dataIndex: 'action',
key: 'action',
width: 200,
},
];
```
### 代码解析
**选择列**:`type: 'selection'` 自动添加复选框列,配合 `@selection-change` 事件实现批量选择。
**列定义**:`title` 为列标题,`dataIndex` 为字段名。
**固定列**:`fixed: 'left'` 固定在左侧,`fixed: 'right'` 固定在右侧(操作列)。
**自定义渲染**:`render(record)` 使用 Vue 的 `h()` 函数创建虚拟 DOM。状态列将 `1/2` 渲染为绿色/红色标签。
**自定义渲染**:`customRender({ record })` 使用 Vue 的 `h()` 函数创建虚拟 DOM。状态列将 `1/2` 渲染为绿色/红色标签。
**宽度设置**:
- `width`:固定宽度
- `minWidth`:最小宽度,可自适应拉伸
**操作列**:需要设置 `key: 'action'`,配合 `bodyCell` 插槽使用。
## 4. 搜索表单 `querySchemas.ts`
@@ -442,7 +424,7 @@ export const columns = [
import { FormSchema } from '@/components/Form/index';
export const schemas: FormSchema[] = [
{
field: 'name',
name: 'name',
component: 'Input',
label: '岗位名称',
componentProps: {
@@ -450,7 +432,7 @@ export const schemas: FormSchema[] = [
},
},
{
field: 'status',
name: 'status',
component: 'Select',
label: '状态',
componentProps: {
@@ -477,10 +459,10 @@ export const schemas: FormSchema[] = [
| 字段 | 说明 |
|:----|:----|
| `field` | 字段名,与后端查询参数对应 |
| `name` | 字段名,与后端查询参数对应 |
| `component` | 表单组件类型:`Input` / `Select` / `DatePicker` 等 |
| `label` | 标签文本 |
| `componentProps` | 组件属性,透传给 ElementPlus 组件 |
| `componentProps` | 组件属性,透传给 AntDesign Vue 组件 |
**Select 组件**:`options` 定义下拉选项,`clearable: true` 允许清空选择。
@@ -502,11 +484,11 @@ edit.vue(编辑弹窗)
1. **一个模块一个目录**:放在 `ui/src/views/{group}/{module}/` 下
2. **4 个文件各司其职**:主页面、编辑弹窗、列定义、搜索 Schema
3. **编辑弹窗是独立组件**:通过 Props 接收 ID,通过 Emit 通知父页面
4. **列定义使用 `h()` 渲染复杂内容**:如状态标签、操作按钮
4. **列定义使用 `customRender` 渲染复杂内容**:如状态标签、操作按钮
5. **搜索 Schema 使用 `FormSchema` 类型**:配置化定义表单字段
6. **新增/编辑通过 `positionId` 区分**:0 为新增,非 0 为编辑
7. **防重复提交使用 `useLockFn`**:提交时按钮 loading
## 总结
前端页面统一使用 `PageWrapper` + `BasicForm` + `BasicTable` + `TableAction` 四件套,分页由 `BasicTable` 内置处理。每个模块包含 `index.vue`(列表页)、`edit.vue`(编辑弹窗)、`columns.ts`(列定义)、`querySchemas.ts`(搜索配置)四个文件。
前端页面统一使用 `PageWrapper` + `BasicForm` + `BasicTable` + `bodyCell` 插槽四件套,分页由 `BasicTable` 内置处理。每个模块包含 `index.vue`(列表页)、`edit.vue`(编辑弹窗)、`columns.ts`(列定义)、`querySchemas.ts`(搜索配置)四个文件。
+164 -75
View File
@@ -10,31 +10,147 @@ src/modules/system/position/models.py
## 完整代码
以下为 `src/modules/system/position/models.py` 的核心代码(省略版权头):
```python
from sqlalchemy import Column, String, Integer, text
"""
岗位模型模块
=============================================================
提供岗位管理的数据模型定义,用于定义员工岗位信息。
企业级特性:
1. 使用枚举类管理状态常量,确保类型安全
2. 状态文本自动转换,便于前端展示
3. 便捷的查询方法,提高开发效率
4. 继承 BaseModel 和 BaseDB,复用通用功能
5. __init__ 由 BaseModel 统一管理,无需重复定义
"""
from enum import IntEnum
from typing import Optional, List
from src.common.base_db import BaseDB
from src.common.base_model import BaseModel
from config.database import DB_PREFIX
from config import DB_PREFIX
from sqlalchemy import text
from src.core.database import db
from src.common.utils.request import like_contains
# ============================================================
# 岗位状态枚举
# ============================================================
class PositionStatus(IntEnum):
"""
岗位状态枚举
使用 IntEnum 确保类型安全,避免魔法数字。
可直接用于数据库查询和比较。
"""
DISABLED = 0 # 停用
ENABLED = 1 # 启用
# ============================================================
# 岗位模型
# ============================================================
class Position(BaseModel, BaseDB):
"""岗位模型类"""
# 表名配置
"""
岗位模型
用于存储员工岗位信息,如:技术总监、产品经理等。
继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。
"""
# 表名:使用配置前缀 + 模型名
__tablename__ = DB_PREFIX + "position"
__table_comment__ = "岗位表"
# 业务字段定义
# 岗位名称
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
# 岗位状态:1-在用 2-停用
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
# 岗位排序
sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序")
# ============================================================
# 字段定义
# ============================================================
def __repr__(self):
"""返回岗位名称作为字符串表示"""
return '岗位:{}'.format(self.name)
# 岗位名称:必填字段,最长255字符
name = db.Column(
db.String(255),
nullable=False,
index=True,
comment="岗位名称"
)
# 岗位状态:使用枚举确保类型安全
# 默认值为停用,创建后需手动启用
status = db.Column(
db.Integer,
index=True,
default=PositionStatus.DISABLED,
server_default=text('0'),
comment="岗位状态:0-停用 1-启用"
)
# 岗位排序:数值越小越靠前
sort = db.Column(
db.Integer,
default=0,
server_default=text('0'),
comment="岗位排序"
)
# ============================================================
# 注意:__init__ 方法在 BaseModel 中已实现,无需重复定义
# BaseModel 已处理驼峰转换、字段过滤等逻辑
# ============================================================
# ============================================================
# 便捷属性
# ============================================================
@property
def status_text(self) -> str:
"""获取岗位状态文本:将数字状态转换为可读的中文文本"""
status_map = {
PositionStatus.DISABLED: '停用',
PositionStatus.ENABLED: '启用'
}
return status_map.get(self.status, '未知')
@property
def is_enabled(self) -> bool:
"""判断岗位是否启用"""
return self.status == PositionStatus.ENABLED
@property
def is_disabled(self) -> bool:
"""判断岗位是否停用"""
return self.status == PositionStatus.DISABLED
# ============================================================
# 自定义查询方法
# ============================================================
@classmethod
def get_enabled(cls) -> List['Position']:
"""获取所有启用的岗位,按排序值升序排列"""
return cls.query.filter(
cls.is_delete == 0,
cls.status == PositionStatus.ENABLED
).order_by(cls.sort.asc()).all()
@classmethod
def get_by_name(cls, name: Optional[str]) -> Optional['Position']:
"""根据岗位名称精确查询"""
if not name:
return None
return cls.query.filter(
cls.name == name,
cls.is_delete == 0
).first()
def __repr__(self) -> str:
return f'岗位:{self.name}'
```
## 代码解析
@@ -48,7 +164,34 @@ class Position(BaseModel, BaseDB):
| 基类 | 来源 | 提供能力 |
|:----|:----|:----|
| `BaseModel` | `src/common/base_model.py` | 通用字段:id、create_user、create_time、update_user、update_time、is_delete |
| `BaseDB` | `src/common/base_db.py` | `save()` 持久化方法、`delete()` 删除方法、`to_dict()` 序列化方法 |
| `BaseDB` | `src/common/base_db.py` | `save()` 持久化方法(仅 flush,不 commit)、`to_dict()` 序列化方法 |
### 状态枚举
项目推荐使用 `IntEnum` 管理状态常量,避免魔法数字:
```python
class PositionStatus(IntEnum):
DISABLED = 0 # 停用
ENABLED = 1 # 启用
```
| 值 | 含义 |
|:----|:----|
| 0 | 停用 / 禁用 |
| 1 | 启用 / 在用 |
软删除字段 `is_delete` 由基类提供:`0` = 正常,`1` = 已删除。
### 字段定义风格
项目使用 `db.Column`(从 `src.core.database` 导入的 `db` 实例),而非直接导入 `Column`:
```python
from src.core.database import db
name = db.Column(db.String(255), nullable=False, index=True, comment="岗位名称")
```
### 表名配置
@@ -56,71 +199,17 @@ class Position(BaseModel, BaseDB):
__tablename__ = DB_PREFIX + "position"
```
`DB_PREFIX` 从 `.env` 读取(默认 `flask_`),最终表名为 `flask_position`。所有业务表都使用此前缀,便于区分框架表与业务表。
### 字段定义
```python
name = Column(String(255), nullable=False, index=True, comment="岗位名称")
```
| 参数 | 说明 |
|:----|:----|
| `String(255)` | 字段类型,VARCHAR(255) |
| `nullable=False` | 非空约束 |
| `index=True` | 创建索引,加速查询 |
| `comment` | 字段注释,同步到数据库 |
```python
status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用")
```
`default=0` 是 Python 端默认值,`server_default=text('0')` 是数据库端默认值。两者都设置可确保无论通过 ORM 还是直接 SQL 插入,都能正确赋默认值。
### 枚举值约定
项目中状态字段统一使用整数枚举:
| 值 | 含义 |
|:----|:----|
| 1 | 启用 / 在用 |
| 2 | 停用 / 禁用 |
软删除字段 `is_delete` 由基类提供:`0` = 正常,`1` = 已删除。
## 数据表 DDL(参考)
模型定义对应的 MySQL 建表语句:
```sql
CREATE TABLE `flask_position` (
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '岗位名称',
`status` int DEFAULT '0' COMMENT '岗位状态:1-在用 2-停用',
`sort` int DEFAULT '0' COMMENT '岗位排序',
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`create_user` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT (now()) COMMENT '创建时间',
`update_user` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT (now()) COMMENT '更新时间',
`is_delete` int DEFAULT '0' COMMENT '删除标识:0-正常 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_flask_position_name` (`name`) USING BTREE,
KEY `idx_flask_position_status` (`status`) USING BTREE,
KEY `idx_flask_position_is_delete` (`is_delete`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='岗位表';
```
:::tip 温馨提示
实际开发中不需要手写 DDL。模型定义完成后,框架会通过 `Base.metadata.create_all()` 自动建表,或通过 `flask db migrate` 生成迁移脚本。
:::
`DB_PREFIX` 从 `.env` 读取(默认 `flask_`),最终表名为 `flask_position`。
## 开发要点
1. **业务字段仅定义差异部分**:通用字段(id、时间、软删除等)由基类自动提供
2. **表名必须加 `DB_PREFIX` 前缀**:保持命名一致性
3. **合理使用索引**:查询频繁的字段(如 name、status)设置 `index=True`
4. **同时设置 `default` 和 `server_default`**:确保 ORM 和直 SQL 两种场景都能正确赋默认值
3. **使用 `db.Column` 而非直接导入 `Column`**:保持与项目一致的导入风格
4. **状态字段使用 IntEnum**:避免魔法数字,提高代码可读性
5. **同时设置 `default` 和 `server_default`**:确保 ORM 和直 SQL 两种场景都能正确赋默认值
6. **`__init__` 由 BaseModel 统一管理**:无需在子类中重复定义
## 总结
数据模型继承 `BaseModel`(公共字段)和 `BaseDB`(save/delete 方法),表名必须加 `DB_PREFIX` 前缀。查询频繁的字段设置索引,同时设置 `default` 和 `server_default` 确保 ORM 和直 SQL 两种场景都能正确赋默认值。
数据模型继承 `BaseModel`(公共字段)和 `BaseDB`(save 方法),表名必须加 `DB_PREFIX` 前缀。状态字段使用 `IntEnum` 枚举类管理,字段定义使用 `db.Column` 风格。`__init__` 由 BaseModel 统一处理驼峰转换和字段过滤,子类无需重复定义。
+142 -105
View File
@@ -10,115 +10,173 @@ src/modules/system/position/schema.py
## 完整代码
以下为 `src/modules/system/position/schema.py` 的核心代码(省略版权头):
```python
from wtforms import StringField, IntegerField
"""
岗位表单验证模块
=============================================================
提供岗位相关的表单验证功能,包括:
- 岗位添加/更新表单验证
- 岗位状态设置表单验证
"""
from wtforms import ValidationError, StringField, IntegerField
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from src.common.forms.base_form import BaseForm
from src.modules.system.position.repository import PositionRepository
# ============================================================
# 岗位表单类
# 岗位表单验证(添加和更新共用)
# ============================================================
class PositionForm(BaseForm):
"""岗位创建/编辑表单"""
name = StringField('岗位名称', validators=[
DataRequired(message='岗位名称不能为空'),
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
])
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
])
sort = IntegerField('岗位排序', validators=[
DataRequired(message='岗位排序不能为空'),
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
])
"""
岗位表单验证类
用于添加和更新岗位时的数据验证。
添加时 id 为 None,更新时 id 必须有值。
"""
# 岗位ID:更新时必填,添加时可选
id = IntegerField(
label='岗位ID',
validators=[
Optional(), # 允许为空(添加时)
NumberRange(min=1, message='岗位ID必须大于0')
],
description='岗位唯一标识,更新时必填,添加时自动生成'
)
# 岗位名称:必填,最长150字符,不可重复
name = StringField(
label='岗位名称',
validators=[
DataRequired(message='岗位名称不能为空'),
Length(max=150, message='岗位名称长度不得超过150个字符')
],
description='岗位显示名称,如:技术总监、产品经理'
)
# 岗位状态:必填,1-在用,2-停用
status = IntegerField(
label='岗位状态',
validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态值在1~2之间')
],
description='岗位状态:1-在用,2-停用'
)
# 岗位排序:可选,0-99999之间的整数
sort = IntegerField(
label='岗位排序',
validators=[
Optional(),
NumberRange(min=0, max=99999, message='岗位排序值在0~99999之间')
],
description='排序值,数值越小越靠前'
)
# ============================================================
# 自定义验证方法
# ============================================================
def validate_name(self, field):
"""
自定义验证:岗位名称不能重复
验证逻辑:
1. 添加时:检查数据库中是否存在同名记录
2. 更新时:排除当前记录自身,检查其他记录是否同名
"""
if not field.data:
return
# 获取当前记录的ID(更新时存在,添加时为None)
current_id = self.id.data if self.id.data else None
# 唯一性校验(软删过滤 + 更新时排除自身)
if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id):
raise ValidationError("岗位名称已存在")
# ============================================================
# 设置状态表单类
# 状态设置表单
# ============================================================
class PositionStatusForm(BaseForm):
"""岗位状态更新表单"""
id = IntegerField('岗位ID', validators=[
DataRequired(message='岗位ID不能为空'),
NumberRange(min=1, message='岗位ID必须大于0')
])
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
])
"""岗位状态设置表单验证类"""
id = IntegerField(
label='岗位ID',
validators=[
DataRequired(message='岗位ID不能为空'),
NumberRange(min=1, message='岗位ID必须大于0')
],
description='要修改状态的岗位ID'
)
status = IntegerField(
label='岗位状态',
validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态值在1~2之间')
],
description='目标状态:1-在用,2-停用'
)
```
## 代码解析
### 基类选择
| 基类 | 用途 | 包含字段 |
| 基类 | 用途 | 说明 |
|:----|:----|:----|
| `BaseForm` | 创建/编辑通用表单 | 自带可选 `id` 字段(`Optional`),编辑时传入 |
| 特定操作表单 | 无额外字段,按需定义 |
| `BaseForm` | 创建/编辑通用表单 | 继承 `flask_wtf.FlaskForm`,支持 ArrayField 处理 |
| `PositionStatusForm` | 状态更新专用 | 只包含 `id` 和 `status` 两个字段 |
创建和编辑共用 `PositionForm`:创建时不传 `id`,编辑时传入 `id`。`BaseForm` 的 `id` 字段定义为 `IntegerField('ID', validators=[Optional()])`,创建时可省略。
### id 字段
创建和编辑共用 `PositionForm`:创建时不传 `id`,编辑时传入 `id`。`id` 字段定义为 `Optional()`,创建时可省略:
```python
id = IntegerField(
label='岗位ID',
validators=[Optional(), NumberRange(min=1, message='岗位ID必须大于0')],
description='岗位唯一标识,更新时必填,添加时自动生成'
)
```
### Validator 约束
```python
name = StringField('岗位名称', validators=[
DataRequired(message='岗位名称不能为空'),
Length(min=1, max=150, message='岗位名称长度必须在1-150个字符之间')
])
```
| Validator | 含义 | 示例 |
|:----|:----|:----|
| `DataRequired` | 必填字段,不允许空值 | `DataRequired(message='岗位名称不能为空')` |
| `Length(max=150)` | 字符串长度限制 | `Length(max=150, message='...')` |
| `NumberRange(min=1, max=2)` | 数值范围限制 | `NumberRange(min=1, max=2, message='...')` |
| `Optional` | 可选字段 | `Optional()` |
| Validator | 含义 |
|:----|:----|
| `DataRequired` | 必填字段,不允许空值 |
| `Length(min=1, max=150)` | 字符串长度限制 |
| `NumberRange(min=1, max=2)` | 数值范围限制 |
| `Optional` | 可选字段 |
### 自定义校验器
通过定义 `validate_{field_name}` 方法实现自定义校验:
```python
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
])
def validate_name(self, field):
"""自定义验证:岗位名称不能重复"""
if not field.data:
return
current_id = self.id.data if self.id.data else None
if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id):
raise ValidationError("岗位名称已存在")
```
| 参数 | 含义 |
|:----|:----|
| `min=1` | 最小值(大于等于) |
| `max=2` | 最大值(小于等于) |
| `message` | 校验失败时的错误信息 |
```python
sort = IntegerField('岗位排序', validators=[
DataRequired(message='岗位排序不能为空'),
NumberRange(min=0, max=99999, message='岗位排序必须在0-99999之间')
])
```
排序字段允许 0 到 99999 的范围。
### 状态更新单独建类
```python
# ============================================================
# 设置状态表单类
# ============================================================
class PositionStatusForm(BaseForm):
"""岗位状态更新表单"""
id = IntegerField('岗位ID', validators=[
DataRequired(message='岗位ID不能为空'),
NumberRange(min=1, message='岗位ID必须大于0')
])
status = IntegerField('岗位状态', validators=[
DataRequired(message='岗位状态不能为空'),
NumberRange(min=1, max=2, message='岗位状态必须是1或2')
])
```
状态更新接口只需要 `id` 和 `status` 两个字段,与创建/编辑表单分开定义,接口语义更清晰。
- 方法名必须为 `validate_{field_name}`
- 校验失败抛出 `ValidationError(错误消息)`
- 唯一性校验通过 `Repository.exists_by(filters, exclude_id)` 实现,自动过滤软删除记录
## 如何在 Router 中使用
@@ -141,7 +199,7 @@ def add():
return R.ok(msg="添加成功")
```
`parse_request_body` 函数自动解析请求体 JSON。校验失败时,Service 层抛出 `BizError`,由异常处理器自动返回标准错误响应:
校验失败时,由框架自动返回标准错误响应:
```json
{
@@ -151,36 +209,15 @@ def add():
}
```
## 自定义校验器
如果需要自定义校验逻辑,可以在表单类中定义 `validate_` 开头的方法:
```python
class PositionForm(BaseForm):
"""岗位创建/编辑表单"""
name = StringField('岗位名称', validators=[
DataRequired(message='岗位名称不能为空'),
Length(max=150, message='岗位名称长度不得超过150个字符')
])
def validate_name(self, field):
"""自定义校验:检查岗位名称是否已存在"""
from src.modules.system.position.repository import PositionRepository
if not field.data:
return
current_id = self.id.data if self.id.data else None
if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id):
raise ValidationError('岗位名称已存在')
```
## 开发要点
1. **创建/编辑共用一个表单类**:继承 `BaseForm`,利用其内置的可选 `id` 字段
2. **特殊操作单独建类**:如状态更新、排序调整等,只包含必要的字段
1. **创建/编辑共用一个表单类**:继承 `BaseForm`,显式定义 `id` 字段(`Optional`)
2. **特殊操作单独建类**:如状态更新,只包含必要的字段
3. **合理设置 Validator**:`DataRequired` / `Length` / `NumberRange` 等,让框架自动校验
4. **message 必填**:用于返回给前端的错误信息,方便调试
5. **唯一性校验**:通过自定义 `validate_` 方法实现,使用 Repository 层的 `exists_by(filters, exclude_id)` 方法
6. **可选字段用 `Optional()`**:如 `sort` 排序字段,不传时使用数据库默认值
## 总结
WTForms Schema 通过 `BaseForm` 继承公共字段(id 等),使用 `validators` 声明约束,自定义 `validate_` 方法实现唯一性校验。唯一性校验使用 `Repository.exists_by({'field': value}, exclude_id=current_id)` 方法,自动过滤软删除记录并支持排除自身。特殊操作单独建类,`message` 必填用于返回给前端的错误信息。
WTForms Schema 通过 `BaseForm` 继承公共能力,使用 `validators` 声明约束,自定义 `validate_` 方法实现唯一性校验。唯一性校验使用 `Repository.exists_by({'field': value}, exclude_id=current_id)` 方法,自动过滤软删除记录并支持排除自身。特殊操作单独建类,`message` 必填用于返回给前端的错误信息。