feat(system): add Excel export for tenant, role, post and site lists

Reuse list filters with ids/columns support and ListExportDropdown UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-21 17:21:50 +08:00
1 parent 5c7210facc
commit 82d8cb7f64
10 files changed
+356 -6

No files matched your search

+204
View File
@@ -37,6 +37,7 @@ from app.schemas.menu_favorite import (
)
from app.services.web_auth_roles import _map_role_name_to_web
from app.utils.excel_export import (
EXPORT_MAX_ROWS,
ExportColumn,
ExportValidationError,
export_rows_to_xlsx_response,
@@ -174,6 +175,55 @@ def list_tenants(
)
TENANT_EXPORT_COLUMNS = [
ExportColumn(title="租户编号", field="tenantNo"),
ExportColumn(title="租户名称", field="tenantName"),
ExportColumn(title="联系人", field="contactName"),
ExportColumn(title="联系电话", field="contactPhone"),
ExportColumn(title="创建时间", field="createTime"),
]
class TenantExportBody(BaseModel):
keyword: Optional[str] = None
ids: Optional[List[str]] = None
columns: Optional[List[str]] = None
@router.post("/tenants/export")
def export_tenants(
body: TenantExportBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
"""导出租户列表(当前筛选全量,或 ids 已选)。"""
try:
items, _ = tenant_svc.list_tenants(
db,
page=1,
page_size=EXPORT_MAX_ROWS,
keyword=body.keyword,
ids=body.ids,
max_page_size=EXPORT_MAX_ROWS,
user_info=_user,
)
cols = filter_export_columns(TENANT_EXPORT_COLUMNS, body.columns)
date_str = datetime.now().strftime("%Y%m%d")
return export_rows_to_xlsx_response(
db,
tenant_no=None,
sheet_title="租户列表",
columns=cols,
rows=items,
filename_stem=f"租户列表_{date_str}",
)
except ExportValidationError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/tenants")
def create_tenant(
body: TenantCreateBody,
@@ -1001,6 +1051,60 @@ def list_sites(
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
SITE_EXPORT_COLUMNS = [
ExportColumn(title="站点名称", field="siteName"),
ExportColumn(title="站点编码", field="siteCode"),
ExportColumn(title="站点类型", field="siteType"),
ExportColumn(title="状态", field="status", dict_type="sys_status"),
ExportColumn(title="地址", field="address"),
ExportColumn(title="联系人", field="contactName"),
ExportColumn(title="联系电话", field="contactPhone"),
ExportColumn(title="创建时间", field="createTime"),
]
class SiteExportBody(BaseModel):
keyword: Optional[str] = None
ids: Optional[List[str]] = None
columns: Optional[List[str]] = None
@router.post("/sites/export")
def export_sites(
body: SiteExportBody,
user: dict = Depends(require_menu_code("system:site:list")),
db: Session = Depends(get_db),
):
"""导出站点列表(当前筛选全量,或 ids 已选)。"""
try:
tenant_no = _get_tenant_no(db, user)
items, _ = site_svc.list_sites(
db,
page=1,
page_size=EXPORT_MAX_ROWS,
keyword=body.keyword,
tenant_no=tenant_no,
ids=body.ids,
max_page_size=EXPORT_MAX_ROWS,
user_info=user,
)
cols = filter_export_columns(SITE_EXPORT_COLUMNS, body.columns)
date_str = datetime.now().strftime("%Y%m%d")
return export_rows_to_xlsx_response(
db,
tenant_no=tenant_no,
sheet_title="站点列表",
columns=cols,
rows=items,
filename_stem=f"站点列表_{date_str}",
)
except ExportValidationError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/sites")
def create_site(
body: SiteCreateBody,
@@ -1110,6 +1214,56 @@ def list_posts(
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
POST_EXPORT_COLUMNS = [
ExportColumn(title="职位名称", field="postName"),
ExportColumn(title="职位编码", field="postCode"),
ExportColumn(title="状态", field="status", dict_type="sys_status"),
ExportColumn(title="创建时间", field="createTime"),
]
class PostExportBody(BaseModel):
keyword: Optional[str] = None
ids: Optional[List[str]] = None
columns: Optional[List[str]] = None
@router.post("/posts/export")
def export_posts(
body: PostExportBody,
user: dict = Depends(require_menu_code("system:post:list")),
db: Session = Depends(get_db),
):
"""导出职位列表(当前筛选全量,或 ids 已选)。"""
try:
tenant_no = _get_tenant_no(db, user)
items, _ = post_svc.list_posts(
db,
page=1,
page_size=EXPORT_MAX_ROWS,
keyword=body.keyword,
tenant_no=tenant_no,
ids=body.ids,
max_page_size=EXPORT_MAX_ROWS,
user_info=user,
)
cols = filter_export_columns(POST_EXPORT_COLUMNS, body.columns)
date_str = datetime.now().strftime("%Y%m%d")
return export_rows_to_xlsx_response(
db,
tenant_no=tenant_no,
sheet_title="职位列表",
columns=cols,
rows=items,
filename_stem=f"职位列表_{date_str}",
)
except ExportValidationError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/posts")
def create_post(
body: PostCreateBody,
@@ -1380,6 +1534,56 @@ def list_role_management(
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
ROLE_EXPORT_COLUMNS = [
ExportColumn(title="角色名称", field="roleName"),
ExportColumn(title="数据权限", field="dataScope", dict_type="sys_data_scope"),
ExportColumn(title="状态", field="status", dict_type="sys_status"),
ExportColumn(title="创建时间", field="createTime"),
]
class RoleExportBody(BaseModel):
keyword: Optional[str] = None
ids: Optional[List[str]] = None
columns: Optional[List[str]] = None
@router.post("/role-management/export")
def export_role_management(
body: RoleExportBody,
user: dict = Depends(require_menu_code("system:role:list")),
db: Session = Depends(get_db),
):
"""导出角色列表(当前筛选全量,或 ids 已选)。"""
try:
tenant_no = _get_tenant_no(db, user)
items, _ = role_svc.list_roles(
db,
page=1,
page_size=EXPORT_MAX_ROWS,
keyword=body.keyword,
tenant_no=tenant_no,
ids=body.ids,
max_page_size=EXPORT_MAX_ROWS,
user_info=user,
)
cols = filter_export_columns(ROLE_EXPORT_COLUMNS, body.columns)
date_str = datetime.now().strftime("%Y%m%d")
return export_rows_to_xlsx_response(
db,
tenant_no=tenant_no,
sheet_title="角色列表",
columns=cols,
rows=items,
filename_stem=f"角色列表_{date_str}",
)
except ExportValidationError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/role-management")
def create_role_management(
body: RoleCreateBody,
+6 -1
View File
@@ -35,6 +35,8 @@ def list_posts(
page_size: int = 10,
keyword: Optional[str] = None,
tenant_no: Optional[str] = None,
ids: Optional[List[str]] = None,
max_page_size: Optional[int] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
@@ -45,12 +47,15 @@ def list_posts(
if kw:
like = f"%{kw}%"
q0 = q0.filter(or_(SysPost.post_name.like(like), SysPost.post_code.like(like)))
if ids:
q0 = q0.filter(SysPost.id.in_(ids))
# 职位为租户主数据,不做 create_by 数据范围过滤
return q0
total = _filtered().order_by(None).with_entities(func.count(SysPost.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 500)
cap = int(max_page_size) if max_page_size else 500
page_size = min(max(1, page_size), cap)
rows = (
_filtered()
.order_by(SysPost.create_time.desc())
+6 -1
View File
@@ -51,6 +51,8 @@ def list_roles(
page_size: int = 10,
keyword: Optional[str] = None,
tenant_no: Optional[str] = None,
ids: Optional[List[str]] = None,
max_page_size: Optional[int] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
@@ -61,6 +63,8 @@ def list_roles(
if kw:
like = f"%{kw}%"
q0 = q0.filter(SysRole.role_name.like(like))
if ids:
q0 = q0.filter(SysRole.id.in_(ids))
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, SysRole, user_info, db, tenant_no)
@@ -68,7 +72,8 @@ def list_roles(
total = _filtered().order_by(None).with_entities(func.count(SysRole.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 500)
cap = int(max_page_size) if max_page_size else 500
page_size = min(max(1, page_size), cap)
rows = (
_filtered()
.order_by(SysRole.create_time.desc())
+6 -1
View File
@@ -40,6 +40,8 @@ def list_sites(
page_size: int = 10,
keyword: Optional[str] = None,
tenant_no: Optional[str] = None,
ids: Optional[List[str]] = None,
max_page_size: Optional[int] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
@@ -50,12 +52,15 @@ def list_sites(
if kw:
like = f"%{kw}%"
q0 = q0.filter(or_(SysSite.site_name.like(like), SysSite.site_code.like(like)))
if ids:
q0 = q0.filter(SysSite.id.in_(ids))
# 站点为租户主数据,不做 create_by 数据范围过滤
return q0
total = _filtered().order_by(None).with_entities(func.count(SysSite.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 500)
cap = int(max_page_size) if max_page_size else 500
page_size = min(max(1, page_size), cap)
rows = (
_filtered()
.order_by(SysSite.create_time.desc())
+6 -1
View File
@@ -35,6 +35,8 @@ def list_tenants(
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
ids: Optional[List[str]] = None,
max_page_size: Optional[int] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
@@ -43,6 +45,8 @@ def list_tenants(
if kw:
like = f"%{kw}%"
q0 = q0.filter(or_(SysTenant.tenant_no.like(like), SysTenant.tenant_name.like(like)))
if ids:
q0 = q0.filter(SysTenant.id.in_(ids))
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, SysTenant, user_info, db)
@@ -50,7 +54,8 @@ def list_tenants(
total = _filtered().order_by(None).with_entities(func.count(SysTenant.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 500)
cap = int(max_page_size) if max_page_size else 500
page_size = min(max(1, page_size), cap)
rows = (
_filtered()
.order_by(SysTenant.create_time.desc())
+36
View File
@@ -52,6 +52,15 @@ export function fetchTenantList(params?: { page?: number; pageSize?: number; key
})
}
export function fetchExportTenants(data: Record<string, any>) {
return request.post({
url: '/web/admin/tenants/export',
data,
responseType: 'blob'
})
}
export function fetchCreateTenant(data: {
tenantNo: string
tenantName: string
@@ -374,6 +383,15 @@ export function fetchSiteList(params?: { page?: number; pageSize?: number; keywo
totalPages: number
}>({ url: '/web/admin/sites', params })
}
export function fetchExportSites(data: Record<string, any>) {
return request.post({
url: '/web/admin/sites/export',
data,
responseType: 'blob'
})
}
export function fetchCreateSite(data: {
siteName: string
siteCode: string
@@ -422,6 +440,15 @@ export function fetchPostList(params?: { page?: number; pageSize?: number; keywo
totalPages: number
}>({ url: '/web/admin/posts', params })
}
export function fetchExportPosts(data: Record<string, any>) {
return request.post({
url: '/web/admin/posts/export',
data,
responseType: 'blob'
})
}
export function fetchCreatePost(data: {
postName: string
postCode: string
@@ -522,6 +549,15 @@ export function fetchRoleList(params?: { page?: number; pageSize?: number; keywo
totalPages: number
}>({ url: '/web/admin/role-management', params })
}
export function fetchExportRoles(data: Record<string, any>) {
return request.post({
url: '/web/admin/role-management/export',
data,
responseType: 'blob'
})
}
export function fetchRoleDetail(id: string) {
return request.get<Api.Role.RoleItem>({ url: `/web/admin/role-management/${id}` })
}
+25
View File
@@ -73,6 +73,15 @@
批量删除
</el-button>
<ListExportDropdown
:export-fn="fetchExportPosts"
:get-filter-params="getPostExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="postExportColumns"
filename="职位列表.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -353,6 +362,7 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
@@ -377,6 +387,7 @@ import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { useDictStore } from '@/store/modules/dict'
import {
fetchPostList,
fetchExportPosts,
fetchCreatePost,
fetchUpdatePost,
fetchDeletePost,
@@ -411,6 +422,20 @@ import { useDictStore } from '@/store/modules/dict'
const selectedRows = ref<Api.Post.PostItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
function getPostExportFilterParams() {
const quickKeyword = quickSearchKeyword.value?.trim()
return {
keyword: quickKeyword || searchForm.keyword || undefined
}
}
const postExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('postName')) cols.unshift('postName')
return cols
})
const dialogVisible = ref(false)
const dialogTitle = ref('新增职位')
const isEdit = ref(false)
+25 -2
View File
@@ -85,6 +85,15 @@
批量删除
</el-button>
<ListExportDropdown
:export-fn="fetchExportRoles"
:get-filter-params="getRoleExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="roleExportColumns"
filename="角色列表.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -189,10 +198,11 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import RoleManageDialogs from '@/components/business/role-manage-dialogs/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import {
canDeleteByCreator,
filterDeletableByCreator,
@@ -211,7 +221,7 @@
} from '@element-plus/icons-vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import { useDictStore } from '@/store/modules/dict'
import { fetchRoleList, fetchDeleteRole } from '@/api/admin'
import { fetchRoleList, fetchExportRoles, fetchDeleteRole } from '@/api/admin'
defineOptions({ name: 'SystemRole' })
@@ -235,6 +245,19 @@
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const roleDialogsRef = ref<InstanceType<typeof RoleManageDialogs> | null>(null)
function getRoleExportFilterParams() {
const quickKeyword = quickSearchKeyword.value?.trim()
return {
keyword: quickKeyword || searchForm.keyword || undefined
}
}
const roleExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('roleName')) cols.unshift('roleName')
return cols
})
const handleSelectionChange = (rows: Api.Role.RoleItem[]) => {
selectedRows.value = rows
}
+17
View File
@@ -41,6 +41,14 @@
批量删除
</el-button>
<ListExportDropdown
:export-fn="fetchExportSites"
:get-filter-params="getSiteExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
filename="站点列表.xlsx"
/>
<ListToolbarEndActions :loading="loading" @refresh="loadData" />
</div>
</div>
@@ -302,6 +310,7 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -311,6 +320,7 @@ import { useDictStore } from '@/store/modules/dict'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import {
fetchSiteList,
fetchExportSites,
fetchCreateSite,
fetchUpdateSite,
fetchDeleteSite,
@@ -377,6 +387,13 @@ const dictStore = useDictStore()
const selectedRows = ref<Api.Site.SiteItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
function getSiteExportFilterParams() {
return {
keyword: searchForm.keyword || undefined
}
}
const dialogVisible = ref(false)
const dialogTitle = ref('新增站点')
const isEdit = ref(false)
@@ -75,6 +75,15 @@
批量删除
</el-button>
<ListExportDropdown
:export-fn="fetchExportTenants"
:get-filter-params="getTenantExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="tenantExportColumns"
filename="租户列表.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -274,6 +283,7 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { ref, reactive, onMounted, computed } from 'vue'
@@ -294,6 +304,7 @@ import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchTenantList,
fetchExportTenants,
fetchCreateTenant,
fetchUpdateTenant,
fetchDeleteTenant
@@ -325,6 +336,20 @@ import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
const selectedRows = ref<Api.Tenant.TenantItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
function getTenantExportFilterParams() {
const quickKeyword = quickSearchKeyword.value?.trim()
return {
keyword: quickKeyword || searchForm.keyword || undefined
}
}
const tenantExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('tenantName')) cols.unshift('tenantName')
return cols
})
const dialogVisible = ref(false)
const dialogTitle = ref('新增租户')
const isEdit = ref(false)