Преглед на файлове

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>
zlwu преди 2 месеца
родител
ревизия
82d8cb7f64

+ 204 - 0
backend/app/api/admin.py

@@ -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
backend/app/crud/post.py

@@ -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
backend/app/crud/role.py

@@ -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
backend/app/crud/site.py

@@ -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
backend/app/crud/tenant.py

@@ -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 - 0
web-admin/src/api/admin.ts

@@ -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 - 0
web-admin/src/views/system/post/index.vue

@@ -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
web-admin/src/views/system/role/index.vue

@@ -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 - 0
web-admin/src/views/system/site/index.vue

@@ -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)

+ 25 - 0
web-admin/src/views/system/tenant/index.vue

@@ -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)