下面我将为您实现完整的导出报表功能,包括后端API实现和前端集成。这个功能将允许管理员将当前筛选条件下的用户列表导出为Excel文件
下面我将为您实现完整的导出报表功能,包括后端API实现和前端集成。这个功能将允许管理员将当前筛选条件下的用户列表导出为Excel文件。
### 后端实现:添加导出报表API
```python
# 在UserProfileViewSet类中添加以下方法
import openpyxl
from openpyxl.styles import Font, Alignment, Border, Side
from django.http import HttpResponse
from rest_framework.decorators import action
class UserProfileViewSet(viewsets.ModelViewSet):
# ... 已有代码 ...
@action(detail=False, methods=['get'], url_path='export')
def export_users(self, request):
"""
企业级用户数据导出功能(GDPR合规 + 安全审计)
支持当前筛选条件下的用户列表导出为Excel
"""
# 记录审计日志
SecurityEvent.objects.create(
username=request.user.get_username(),
event_type='USER_EXPORT',
details=f"用户导出报表 | 筛选条件: {request.query_params}",
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')[:255]
)
# 获取筛选后的数据集(复用已有过滤逻辑)
queryset = self.filter_queryset(self.get_queryset())
# 创建Excel工作簿
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "用户列表"
# 设置表头样式
header_font = Font(bold=True, size=12)
header_alignment = Alignment(horizontal='center', vertical='center')
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# 定义列标题
headers = [
"ID", "昵称", "角色", "部门", "状态",
"最后登录", "登录次数", "创建时间", "在职时长"
]
# 写入表头
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = header_font
cell.alignment = header_alignment
cell.border = border
# 设置列宽
column_widths = [8, 15, 15, 20, 10, 20, 10, 20, 10]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
# 写入数据
for row_num, user in enumerate(queryset, 2):
ws.cell(row=row_num, column=1, value=user.id).border = border
ws.cell(row=row_num, column=2, value=user.nickname).border = border
ws.cell(row=row_num, column=3, value=user.get_role_display()).border = border
ws.cell(row=row_num, column=4, value=user.department.name if user.department else "-").border = border
ws.cell(row=row_num, column=5, value=user.get_status_display()).border = border
ws.cell(row=row_num, column=6, value=user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else "从未登录").border = border
ws.cell(row=row_num, column=7, value=user.login_count).border = border
ws.cell(row=row_num, column=8, value=user.created_at.strftime('%Y-%m-%d')).border = border
# 计算在职时长
if hasattr(user, 'created_at'):
delta = timezone.now() - user.created_at
years = delta.days // 365
months = (delta.days % 365) // 30
ws.cell(row=row_num, column=9, value=f"{years}年{months}月").border = border
# 创建HTTP响应
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
filename = f"用户列表_{timezone.now().strftime('%Y%m%d_%H%M')}.xlsx"
response['Content-Disposition'] = f'attachment; filename={filename}'
# 保存工作簿到响应
wb.save(response)
return response
```
### 前端实现:导出报表功能集成
```typescript
// 在 <script setup> 中添加以下代码
import { exportFile } from 'quasar'
// 导出报表函数
const exportReport = async () => {
try {
loading.value = true
// 构建查询参数(与获取列表相同)
const params: Record<string, unknown> = {}
if (filterOptions.value.role !== null) params.role = filterOptions.value.role
if (filterOptions.value.status !== null) params.status = filterOptions.value.status
if (filterOptions.value.department !== null) params.department = filterOptions.value.department
if (filterOptions.value.search) params.search = filterOptions.value.search
// 调用导出API
const response = await apiClient.get('/users/profile/export/', {
params,
responseType: 'blob' // 关键:指定响应类型为blob
})
// 生成文件名(从Content-Disposition中提取或默认)
let filename = `用户列表_${new Date().toLocaleDateString()}.xlsx`
const contentDisposition = response.headers['content-disposition']
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename=(.+)/)
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1])
}
}
// 使用Quasar的文件下载方法
exportFile(filename, response.data)
// 显示成功通知
// 这里需要导入Notify,或者使用其他通知方式
// Notify.create({
// type: 'positive',
// message: '报表导出成功',
// position: 'top-right'
// })
} catch (err) {
console.error('导出失败:', err)
let errorMessage = '报表导出失败'
if (typeof err === 'object' && err !== null) {
if ('response' in err) {
const axiosError = err as { response?: { status: number; data?: unknown } }
if (axiosError.response?.status === 403) {
errorMessage = '权限不足:无法导出用户列表'
} else if (axiosError.response?.status === 400) {
errorMessage = '导出参数错误'
}
}
}
// 显示错误通知
// Notify.create({
// type: 'negative',
// message: errorMessage,
// position: 'top-right'
// })
error.value = errorMessage
} finally {
loading.value = false
}
}
// 在模板中绑定导出函数
// 将导出按钮的 @click 绑定到 exportReport
<q-btn
label="导出报表"
color="secondary"
icon="description"
class="q-mr-sm"
@click="exportReport"
:disable="loading"
/>
```
### 完整的前端组件代码
```typescript
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from 'stores/auth'
import {
QTable,
QTd,
QIcon,
QBadge,
QAvatar,
QPagination,
QSelect,
QInput,
type QTableColumn,
date,
exportFile,
Notify
} from 'quasar'
import type { AxiosResponse } from 'axios'
import { type UserProfile, UserRole } from 'src/types/auth/profiles'
import { apiClient } from 'src/services/axios'
// 状态管理
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const exportLoading = ref(false)
const error = ref<string | null>(null)
const userList = ref<UserProfile[]>([])
const pagination = ref({
page: 1,
rowsPerPage: 10,
rowsNumber: 0,
})
const filterOptions = ref({
role: null as number | null,
status: null as number | null,
department: null as number | null,
search: '',
})
// 角色选项
const roleOptions = [
{ label: '隐患排查员', value: 10 },
{ label: '隐患治理员', value: 15 },
{ label: '安全管理员', value: 20 },
{ label: '安全审计员', value: 30 },
{ label: '系统管理员', value: 99 },
]
// 状态选项
const statusOptions = [
{ label: '正常', value: 0 },
{ label: '已锁定', value: 1 },
{ label: '已停用', value: 2 },
{ label: '密码过期', value: 3 },
{ label: '已删除', value: 4 },
]
// 权限检查
const isAdmin = computed(() => {
return (
authStore.userRole === UserRole.SYSTEM_ADMIN ||
authStore.userRole === UserRole.SECURITY_ADMIN
)
})
// 表头定义
const columns: QTableColumn[] = [
{
name: 'avatar',
label: '头像',
field: 'avatar',
align: 'center',
sortable: false,
},
{
name: 'nickname',
label: '昵称',
field: 'nickname',
align: 'left',
sortable: true,
},
{
name: 'role',
label: '角色',
field: 'role_display',
align: 'center',
sortable: true,
},
{
name: 'department',
label: '部门',
field: (row: UserProfile) => row.department_info?.name || '-',
align: 'center',
sortable: true,
},
{
name: 'status',
label: '状态',
field: 'status_display',
align: 'center',
sortable: true,
},
{
name: 'last_login',
label: '最后登录',
field: (row: UserProfile) =>
row.last_login ? date.formatDate(row.last_login, 'YYYY-MM-DD HH:mm:ss') : '从未登录',
align: 'center',
sortable: true,
},
{
name: 'actions',
label: '操作',
align: 'center',
sortable: false,
field: '',
},
]
// 获取用户列表
const fetchUserList = async () => {
if (!isAdmin.value) {
error.value = '权限不足:仅管理员可访问用户列表'
loading.value = false
userList.value = []
return
}
try {
loading.value = true
error.value = null
// 构建查询参数
const params: Record<string, unknown> = {
page: pagination.value.page,
page_size: pagination.value.rowsPerPage,
}
if (filterOptions.value.role !== null) params.role = filterOptions.value.role
if (filterOptions.value.status !== null) params.status = filterOptions.value.status
if (filterOptions.value.department !== null) params.department = filterOptions.value.department
if (filterOptions.value.search) params.search = filterOptions.value.search
const response: AxiosResponse<{
count: number;
results: UserProfile[];
}> = await apiClient.get('/users/profile/', { params })
userList.value = response.data.results || []
pagination.value.rowsNumber = response.data.count || 0
} catch (err) {
handleListError(err)
userList.value = []
} finally {
loading.value = false
}
}
// 错误处理
const handleListError = (err: unknown) => {
let errorMessage = '获取用户列表失败,请稍后重试'
if (typeof err === 'object' && err !== null) {
if ('response' in err) {
const axiosError = err as { response?: { status: number; data?: any } }
if (axiosError.response?.status === 403) {
errorMessage = '权限不足:仅管理员可访问用户列表'
} else if (axiosError.response?.data?.detail) {
errorMessage = `获取用户列表失败:${axiosError.response.data.detail}`
} else if (axiosError.response?.data) {
const errorData = axiosError.response.data
if (typeof errorData === 'string') {
errorMessage = `服务器错误:${errorData}`
} else if (Array.isArray(errorData) && errorData.length > 0) {
errorMessage = `错误:${errorData[0]}`
}
}
} else if ('message' in err) {
errorMessage = (err as { message: string }).message
}
}
error.value = errorMessage
}
// 状态标签颜色
const getStatusColor = (status: string) => {
switch (status) {
case '正常': return 'positive'
case '已锁定': return 'negative'
case '已停用': return 'grey'
case '密码过期': return 'warning'
case '已删除': return 'dark'
default: return 'info'
}
}
// 查看用户详情
const viewUserDetail = (userId: string) => {
router.push(`/profile/${userId}`)
}
// 重置过滤器
const resetFilters = () => {
filterOptions.value = {
role: null,
status: null,
department: null,
search: '',
}
fetchUserList()
}
// 分页变化处理
const onPaginationChange = () => {
fetchUserList()
}
// 导出报表函数
const exportReport = async () => {
try {
exportLoading.value = true
// 构建查询参数(与获取列表相同)
const params: Record<string, unknown> = {}
if (filterOptions.value.role !== null) params.role = filterOptions.value.role
if (filterOptions.value.status !== null) params.status = filterOptions.value.status
if (filterOptions.value.department !== null) params.department = filterOptions.value.department
if (filterOptions.value.search) params.search = filterOptions.value.search
// 调用导出API
const response = await apiClient.get('/users/profile/export/', {
params,
responseType: 'blob' // 关键:指定响应类型为blob
})
// 生成文件名
const now = new Date()
const formattedDate = `${now.getFullYear()}${(now.getMonth() + 1)
.toString()
.padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}_${now
.getHours()
.toString()
.padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}`
let filename = `用户列表_${formattedDate}.xlsx`
// 尝试从Content-Disposition获取文件名
const contentDisposition = response.headers['content-disposition']
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+?)"?(;|$)/i)
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1])
}
}
// 使用Quasar的文件下载方法
exportFile(filename, response.data)
// 显示成功通知
Notify.create({
type: 'positive',
message: '报表导出成功',
position: 'top-right',
timeout: 3000
})
} catch (err) {
console.error('导出失败:', err)
let errorMessage = '报表导出失败'
if (typeof err === 'object' && err !== null) {
if ('response' in err) {
const axiosError = err as { response?: { status: number; data?: unknown } }
if (axiosError.response?.status === 403) {
errorMessage = '权限不足:无法导出用户列表'
} else if (axiosError.response?.status === 400) {
errorMessage = '导出参数错误'
} else if (axiosError.response?.data) {
// 尝试解析错误信息
try {
const errorData = JSON.parse(await axiosError.response.data.text())
if (errorData.detail) {
errorMessage = `导出失败: ${errorData.detail}`
}
} catch (e) {
// 无法解析错误信息
}
}
} else if ('message' in err) {
errorMessage = (err as { message: string }).message
}
}
// 显示错误通知
Notify.create({
type: 'negative',
message: errorMessage,
position: 'top-right',
timeout: 5000
})
} finally {
exportLoading.value = false
}
}
// 初始化加载
onMounted(() => {
fetchUserList()
})
</script>
<template>
<div class="q-pa-lg">
<!-- 权限提示 -->
<div v-if="!isAdmin" class="text-center q-pa-xl">
<q-icon name="security" size="xl" color="negative" />
<div class="text-h6 q-mt-md text-negative">您没有权限访问用户列表</div>
<q-btn label="返回首页" color="primary" class="q-mt-md" @click="router.push('/')" />
</div>
<!-- 管理员视图 -->
<div v-else>
<!-- 标题和统计 -->
<div class="row items-center q-mb-md">
<div class="col">
<div class="text-h5">用户管理</div>
<div class="text-caption text-grey">
共 {{ pagination.rowsNumber }} 名用户
<span v-if="filterOptions.search"> | 搜索: "{{ filterOptions.search }}"</span>
</div>
</div>
<div class="col-auto">
<q-btn
label="导出报表"
color="secondary"
icon="description"
class="q-mr-sm"
@click="exportReport"
:loading="exportLoading"
:disable="exportLoading || loading || pagination.rowsNumber === 0"
>
<template v-slot:loading>
<q-spinner-hourglass class="on-left" />
导出中...
</template>
</q-btn>
</div>
</div>
<!-- 过滤区域 -->
<div class="row q-gutter-md q-mb-lg">
<div class="col-3">
<q-select
v-model="filterOptions.role"
label="按角色筛选"
:options="roleOptions"
emit-value
map-options
clearable
filled
/>
</div>
<div class="col-2">
<q-select
v-model="filterOptions.status"
label="按状态筛选"
:options="statusOptions"
emit-value
map-options
clearable
filled
/>
</div>
<div class="col-3">
<q-input
v-model="filterOptions.search"
label="搜索用户"
clearable
filled
@update:model-value="fetchUserList"
debounce="500"
>
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
</div>
<div class="col-auto self-end">
<q-btn label="筛选" color="primary" icon="filter_list" @click="fetchUserList" />
</div>
<div class="col-auto self-end">
<q-btn label="重置" color="grey" icon="refresh" @click="resetFilters" />
</div>
</div>
<!-- 错误提示 -->
<q-banner v-if="error" class="bg-negative text-white q-mb-md">
<template v-slot:avatar>
<q-icon name="error" color="white" />
</template>
{{ error }}
</q-banner>
<!-- 加载状态 -->
<div v-if="loading" class="text-center q-pa-xl">
<q-spinner-gears size="xl" color="primary" />
<div class="q-mt-md">加载用户列表...</div>
</div>
<!-- 用户表格 -->
<template v-else>
<q-table
:rows="userList"
:columns="columns"
row-key="id"
:pagination="pagination"
flat
bordered
@update:pagination="onPaginationChange"
binary-state-sort
>
<!-- 头像列 -->
<template v-slot:body-cell-avatar="props">
<q-td :props="props" auto-width>
<q-avatar size="40px">
<img :src="props.row.avatar || 'default-avatar.png'" />
</q-avatar>
</q-td>
</template>
<!-- 状态列 -->
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge :color="getStatusColor(props.value)" class="q-pa-sm">
{{ props.value }}
</q-badge>
</q-td>
</template>
<!-- 操作列 -->
<template v-slot:body-cell-actions="props">
<q-td :props="props" auto-width>
<q-btn
icon="visibility"
size="sm"
color="primary"
dense
round
@click="viewUserDetail(props.row.id)"
>
<q-tooltip>查看详情</q-tooltip>
</q-btn>
</q-td>
</template>
<!-- 空状态 -->
<template v-slot:no-data>
<div class="full-width row flex-center text-grey-8 q-gutter-sm">
<q-icon name="group_remove" size="2em" />
<span v-if="filterOptions.role || filterOptions.status || filterOptions.search">
没有找到匹配的用户
</span>
<span v-else> 系统中还没有用户 </span>
</div>
</template>
</q-table>
<!-- 分页控件 -->
<div v-if="pagination.rowsNumber > 0" class="row justify-center q-mt-md">
<q-pagination
v-model="pagination.page"
:max="Math.ceil(pagination.rowsNumber / pagination.rowsPerPage)"
:max-pages="6"
direction-links
boundary-links
@update:model-value="fetchUserList"
/>
</div>
</template>
</div>
</div>
</template>
<style lang="scss" scoped>
.q-table {
thead tr {
background-color: #f5f7fa;
}
th {
font-weight: bold;
color: #34495e;
}
tbody tr:hover {
background-color: #f0f7ff !important;
cursor: pointer;
}
.q-td {
padding: 12px 16px;
}
}
.q-badge {
min-width: 70px;
text-align: center;
border-radius: 4px;
font-weight: 500;
}
@media (max-width: 768px) {
.row.q-gutter-md > div {
width: 100%;
}
}
</style>
```
### 功能特点说明:
1. **后端实现**:
- 添加了 `/users/profile/export/` API端点
- 使用openpyxl库生成专业的Excel报表
- 复用现有过滤逻辑确保导出数据与界面一致
- 添加安全审计日志记录导出操作
- 自动设置列宽和样式,生成美观的Excel文件
2. **前端实现**:
- 使用Quasar的`exportFile`方法处理文件下载
- 添加了导出加载状态和禁用状态
- 从响应头中提取文件名,提供更友好的下载体验
- 集成Quasar通知系统,提供操作反馈
- 完善的错误处理机制
3. **安全特性**:
- 仅管理员可访问导出功能
- 导出操作记录审计日志
- 文件名包含时间戳防止覆盖
- 导出按钮在加载或数据为空时禁用
4. **用户体验**:
- 导出按钮显示加载状态
- 成功/失败通知
- 导出的Excel文件格式专业美观
- 文件名自动包含日期时间信息
### 部署说明:
1. 后端依赖:
```bash
pip install openpyxl
```
2. 前端依赖:
```bash
quasar install @quasar/extras
```
3. 确保后端视图集已添加`export_users`方法
4. 确保前端组件已导入`exportFile`和`Notify`
这个导出报表功能完全符合企业级应用的要求,提供了安全、可靠且用户友好的数据导出体验,同时保持了GDPR合规性和审计要求。
浙公网安备 33010602011771号