This commit is contained in:
ChuXun
2025-10-19 20:31:01 +08:00
parent cfd054f0d9
commit 4ce487588a
287 changed files with 59148 additions and 0 deletions

View File

@@ -0,0 +1,313 @@
# 仪表盘页面设计文档
## 1. 页面概述
仪表盘页面是用户登录后看到的第一个界面,旨在提供系统关键信息的概览。它主要面向管理员、决策者和主管,展示核心业务指标、任务状态分布、近期活动等,以便用户快速掌握系统整体状况并做出决策。
## 2. 页面布局
![仪表盘页面布局示意图](https://placeholder-for-dashboard-page-mockup.png)
### 2.1 布局结构
页面采用响应式网格布局,确保在不同屏幕尺寸下都有良好的可读性。
- **顶部**: 页面标题和一个时间范围选择器,用于筛选仪表盘数据。
- **中部**: 关键指标卡片区域,展示核心数据(如总任务数、待处理反馈等)。
- **下部**:
- **左侧**: 图表区域,通过饼图和柱状图展示任务状态和类型的分布。
- **右侧**: 近期活动或任务列表,展示最新的任务分配或状态变更。
### 2.2 响应式设计
- **桌面端**: 采用多列网格布局,充分利用屏幕空间。
- **平板端**: 网格列数减少,卡片和图表垂直堆叠。
- **移动端**: 单列布局,所有模块垂直排列,保证可读性。
## 3. 组件结构
```vue
<template>
<div class="dashboard-page">
<!-- 页面头部 -->
<el-page-header title="仪表盘" content="系统概览" class="page-header" />
<!-- 时间范围选择器 -->
<div class="dashboard-filters">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
</div>
<!-- 加载与错误状态 -->
<div v-if="loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="error" class="error-state">
<el-alert type="error" :title="error" show-icon :closable="false" />
</div>
<!-- 数据内容 -->
<div v-else class="dashboard-content">
<!-- 关键指标卡片 -->
<el-row :gutter="20" class="summary-cards">
<el-col :span="6" :xs="24" :sm="12" :md="6">
<StatisticCard icon="list" title="总任务数" :value="stats.totalTasks" color="#409eff" />
</el-col>
<el-col :span="6" :xs="24" :sm="12" :md="6">
<StatisticCard icon="clock" title="待处理任务" :value="stats.pendingTasks" color="#e6a23c" />
</el-col>
<el-col :span="6" :xs="24" :sm="12" :md="6">
<StatisticCard icon="chat-dot-round" title="待审核反馈" :value="stats.pendingFeedback" color="#f56c6c" />
</el-col>
<el-col :span="6" :xs="24" :sm="12" :md="6">
<StatisticCard icon="user" title="活跃用户" :value="stats.activeUsers" color="#67c23a" />
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20" class="charts-section">
<el-col :span="12" :xs="24" :sm="24" :md="12">
<el-card shadow="never">
<template #header>任务状态分布</template>
<PieChart :chart-data="taskStatusChartData" />
</el-card>
</el-col>
<el-col :span="12" :xs="24" :sm="24" :md="12">
<el-card shadow="never">
<template #header>任务类型分布</template>
<BarChart :chart-data="taskTypeChartData" />
</el-card>
</el-col>
</el-row>
<!-- 近期任务列表 -->
<el-card shadow="never" class="recent-tasks-section">
<template #header>近期任务动态</template>
<el-table :data="recentTasks" stripe>
<el-table-column prop="title" label="任务标题" />
<el-table-column prop="assignee" label="执行人" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" />
</el-table>
</el-card>
</div>
</div>
</template>
```
## 4. 数据结构
```typescript
// 仪表盘统计数据
interface DashboardStats {
totalTasks: number;
pendingTasks: number;
pendingFeedback: number;
activeUsers: number;
}
// 图表数据项
interface ChartDataItem {
name: string;
value: number;
}
// 近期任务项
interface RecentTask {
id: number;
title: string;
assignee: string;
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'REVIEWED';
updatedAt: string;
}
// 仪表盘完整数据
interface DashboardData {
stats: DashboardStats;
taskStatusDistribution: ChartDataItem[];
taskTypeDistribution: ChartDataItem[];
recentTasks: RecentTask[];
}
```
## 5. 状态管理
```typescript
// 组件内状态
const dateRange = ref<[Date, Date]>();
const loading = ref<boolean>(true);
const error = ref<string | null>(null);
// 全局状态 (Pinia Store)
const dashboardStore = useDashboardStore();
const { stats, taskStatusChartData, taskTypeChartData, recentTasks } = storeToRefs(dashboardStore);
```
## 6. 交互逻辑
### 6.1 数据加载
```typescript
// 在组件挂载时加载数据
onMounted(() => {
fetchDashboardData();
});
const fetchDashboardData = async () => {
try {
loading.value = true;
error.value = null;
// 从 store 中调用 action 加载数据
await dashboardStore.fetchDashboardData({
startDate: dateRange.value?.[0],
endDate: dateRange.value?.[1],
});
} catch (err) {
error.value = '仪表盘数据加载失败,请稍后重试。';
} finally {
loading.value = false;
}
};
// 当日期范围变化时重新加载数据
const handleDateChange = () => {
fetchDashboardData();
};
```
### 6.2 辅助函数
```typescript
// 根据任务状态返回不同的标签类型
const getStatusTagType = (status: RecentTask['status']) => {
switch (status) {
case 'PENDING': return 'warning';
case 'IN_PROGRESS': return 'primary';
case 'COMPLETED': return 'success';
case 'REVIEWED': return 'info';
default: return 'info';
}
};
```
## 7. API 调用
### 7.1 仪表盘 API
```typescript
// api/dashboard.ts
export const dashboardApi = {
getDashboardData: (params: { startDate?: Date; endDate?: Date }) =>
apiClient.get<DashboardData>('/dashboard', { params })
};
// stores/dashboard.ts
export const useDashboardStore = defineStore('dashboard', {
state: (): DashboardData => ({
stats: { totalTasks: 0, pendingTasks: 0, pendingFeedback: 0, activeUsers: 0 },
taskStatusDistribution: [],
taskTypeDistribution: [],
recentTasks: [],
}),
getters: {
// 将后端数据转换为 ECharts 需要的格式
taskStatusChartData: (state) => ({
legend: state.taskStatusDistribution.map(item => item.name),
series: [{ data: state.taskStatusDistribution }]
}),
taskTypeChartData: (state) => ({
xAxis: state.taskTypeDistribution.map(item => item.name),
series: [{ data: state.taskTypeDistribution.map(item => item.value) }]
})
},
actions: {
async fetchDashboardData(params) {
try {
const { data } = await dashboardApi.getDashboardData(params);
// 更新整个 state
this.$patch(data);
return data;
} catch (error) {
console.error('获取仪表盘数据失败:', error);
throw error;
}
}
}
});
```
## 8. 样式设计
```scss
.dashboard-page {
padding: 24px;
.page-header {
margin-bottom: 24px;
}
.dashboard-filters {
margin-bottom: 24px;
}
.summary-cards {
margin-bottom: 20px;
}
.charts-section {
margin-bottom: 20px;
}
.el-card {
border: none;
border-radius: 8px;
}
.loading-state,
.error-state {
padding: 40px;
}
}
// 响应式样式
@media screen and (max-width: 768px) {
.dashboard-page {
padding: 16px;
.summary-cards .el-col,
.charts-section .el-col {
margin-bottom: 20px;
}
}
}
```
## 9. 测试用例
1. **单元测试**
- 测试 `getStatusTagType` 辅助函数的正确性。
- 测试 Pinia store中getters的数据转换逻辑。
- 测试 `StatisticCard``PieChart``BarChart` 组件的渲染是否正确。
2. **集成测试**
- 测试页面在加载、成功、失败状态下的显示。
- 测试日期选择器筛选功能是否能正确触发数据重新加载。
- 模拟 API 调用,验证数据是否正确渲染到页面上。
## 10. 性能优化
1. **懒加载图表**: 使用 `v-if` 或动态导入,确保图表库只在需要时加载和渲染。
2. **骨架屏**: 在数据加载时使用骨架屏,提升用户体验。
3. **数据缓存**: 对不经常变化的数据(如任务类型),可以在 Pinia store 中设置缓存策略,避免重复请求。
4. **节流/防抖**: 对日期选择器的 `change` 事件使用节流或防抖,防止用户快速操作导致频繁的 API 请求。

View File

@@ -0,0 +1,261 @@
# 反馈管理页面设计文档
## 1. 页面概述
反馈管理页面是管理员和主管处理用户提交的环境问题报告、建议或其他反馈的核心平台。此页面旨在提供一个高效的工作流程,用于审查、分类、回复和跟踪所有用户反馈,确保问题得到及时响应和解决。
## 2. 页面布局
![反馈管理页面布局示意图](https://placeholder-for-feedback-page-mockup.png)
### 2.1 布局结构
页面布局与任务管理页面类似,遵循标准的后台管理界面设计:
- **顶部**: 筛选区域,允许管理员按反馈状态(待处理、已处理)、反馈类型或提交时间进行过滤。
- **中部**: 反馈列表,以表格或卡片列表的形式展示反馈信息。
- **底部**: 分页控件。
### 2.2 响应式设计
- **桌面端**: 使用多列数据表格,清晰展示反馈的各项信息。
- **移动端**: 转换为卡片式列表,每张卡片突出显示反馈标题、提交人和状态,并提供点击查看详情的入口。
## 3. 组件结构
```vue
<template>
<div class="feedback-management-page">
<el-page-header title="反馈管理" content="用户反馈列表" />
<el-card class="page-container">
<!-- 筛选区域 -->
<div class="filter-container">
<el-form :model="filters" inline>
<el-form-item label="处理状态">
<el-select v-model="filters.status" placeholder="请选择状态" clearable>
<el-option label="待处理" value="PENDING" />
<el-option label="已处理" value="RESOLVED" />
</el-select>
</el-form-item>
<el-form-item label="提交人">
<el-input v-model="filters.submitter" placeholder="输入提交人姓名/ID" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
</div>
<!-- 反馈表格 -->
<el-table :data="feedbackList" v-loading="loading" stripe>
<el-table-column prop="title" label="反馈标题" min-width="200" />
<el-table-column prop="submitterName" label="提交人" width="120" />
<el-table-column prop="createdAt" label="提交时间" width="180" sortable />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'PENDING' ? 'warning' : 'success'">
{{ row.status === 'PENDING' ? '待处理' : '已处理' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewDetails(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-if="total > 0"
class="pagination-container"
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
<!-- 反馈详情对话框 -->
<FeedbackDetailDialog
v-model="dialogVisible"
:feedback-id="selectedFeedbackId"
@processed="handleProcessed"
/>
</div>
</template>
```
## 4. 数据结构
```typescript
// 反馈筛选条件
interface FeedbackFilters {
status?: 'PENDING' | 'RESOLVED';
submitter?: string;
}
// 反馈列表项
interface FeedbackListItem {
id: number;
title: string;
submitterName: string;
createdAt: string;
status: 'PENDING' | 'RESOLVED';
}
// 反馈详情 (用于对话框)
interface FeedbackDetail extends FeedbackListItem {
content: string;
images: string[]; // 图片 URL 列表
location: string;
contact: string;
handlerNotes?: string; // 处理备注
}
```
## 5. 状态管理
```typescript
// 组件内状态
const filters = ref<FeedbackFilters>({});
const pagination = ref({ page: 1, pageSize: 10 });
const dialogVisible = ref(false);
const selectedFeedbackId = ref<number | null>(null);
// 全局状态 (Pinia Store)
const feedbackStore = useFeedbackStore();
const { feedbackList, total, loading } = storeToRefs(feedbackStore);
```
## 6. 交互逻辑
### 6.1 数据获取与筛选
```typescript
const fetchFeedback = () => {
feedbackStore.fetchFeedbackList({ ...filters.value, ...pagination.value });
};
onMounted(fetchFeedback);
const handleSearch = () => {
pagination.value.page = 1;
fetchFeedback();
};
// 分页逻辑 (handleSizeChange, handleCurrentChange) 与任务管理页面类似
```
### 6.2 查看详情与处理
```typescript
const handleViewDetails = (feedback: FeedbackListItem) => {
selectedFeedbackId.value = feedback.id;
dialogVisible.value = true;
};
// 当详情对话框中完成处理后被调用
const handleProcessed = () => {
dialogVisible.value = false;
fetchFeedback(); // 刷新列表以更新状态
};
```
## 7. API 调用
### 7.1 反馈管理 API
```typescript
// api/feedback.ts
export const feedbackApi = {
getFeedbackList: (params) => apiClient.get('/management/feedback', { params }),
getFeedbackDetail: (id: number) => apiClient.get(`/management/feedback/${id}`),
processFeedback: (id: number, data: { notes: string }) => apiClient.post(`/management/feedback/${id}/process`, data),
};
```
### 7.2 Pinia Store
```typescript
// stores/feedback.ts
export const useFeedbackStore = defineStore('feedback', {
state: () => ({
feedbackList: [] as FeedbackListItem[],
total: 0,
loading: false,
currentFeedbackDetail: null as FeedbackDetail | null,
}),
actions: {
async fetchFeedbackList(params) {
this.loading = true;
try {
const { data } = await feedbackApi.getFeedbackList(params);
this.feedbackList = data.items;
this.total = data.total;
} finally {
this.loading = false;
}
},
async fetchFeedbackDetail(id: number) {
const { data } = await feedbackApi.getFeedbackDetail(id);
this.currentFeedbackDetail = data;
},
async processFeedback(payload: { id: number; notes: string }) {
await feedbackApi.processFeedback(payload.id, { notes: payload.notes });
}
}
});
```
## 8. 样式设计
```scss
.feedback-management-page {
padding: 24px;
.page-container {
margin-top: 24px;
padding: 24px;
border-radius: 8px;
border: none;
}
.filter-container {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
}
```
## 9. 关联组件
### `FeedbackDetailDialog.vue`
这是一个关键的子组件,用于显示反馈的完整信息,并提供处理功能。
- **Props**: `modelValue` (for v-model), `feedbackId`
- **功能**:
- 接收 `feedbackId`,在对话框打开时调用 store action 获取反馈详情。
- 展示反馈的文本内容、联系方式、地理位置和图片(图片可点击放大预览)。
- 提供一个文本域供管理员填写处理备注。
- 提供一个"标记为已处理"的按钮,点击后调用 store action 提交处理结果。
- 操作成功后emit 一个 `processed` 事件通知父组件刷新列表。
## 10. 测试用例
1. **单元测试**:
- 测试 Pinia store 的各个 action 是否能正常工作。
- 测试 `FeedbackDetailDialog` 组件在接收到 `feedbackId` 后是否能正确加载和显示数据。
2. **集成测试**:
- 测试筛选功能是否能正确过滤反馈列表。
- 测试分页是否正常。
- 测试点击"查看详情"按钮是否能打开对话框并加载正确的反馈信息。
- 测试在详情对话框中提交处理意见后,列表中的反馈状态是否更新。

View File

@@ -0,0 +1,240 @@
# 文件管理页面设计文档
## 1. 页面概述
文件管理页面为系统管理员提供了一个集中管理所有上传文件的界面。这主要包括用户在提交反馈、任务报告等流程中上传的图片或其他附件。管理员能够在此页面上预览、搜索、筛选和删除文件,以维护系统存储的整洁和合规性。
## 2. 页面布局
![文件管理页面布局示意图](https://placeholder-for-files-page-mockup.png)
### 2.1 布局结构
页面将采用现代化的卡片式画廊Gallery布局以优化图片等视觉文件的预览体验。
- **顶部**: 操作和筛选区域。包含一个上传按钮(如果允许管理员直接上传)和按文件名、上传者或关联模块(如"反馈"、"任务")进行筛选的控件。
- **中部**: 文件画廊/列表。以卡片网格的形式展示文件,每个卡片包含文件缩略图、文件名、大小、上传时间等信息。
- **底部**: 分页控件。
## 3. 组件结构
```vue
<template>
<div class="file-management-page">
<el-page-header title="文件管理" content="系统上传资源概览" />
<el-card class="page-container">
<!-- 筛选与上传 -->
<div class="table-toolbar">
<el-form :model="filters" inline>
<el-form-item>
<el-input v-model="filters.filename" placeholder="按文件名搜索" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
<!-- <el-upload action="/api/files/upload" ... >
<el-button type="primary" icon="Upload">上传文件</el-button>
</el-upload> -->
</div>
<!-- 文件画廊 -->
<el-scrollbar v-loading="loading">
<div class="file-gallery">
<el-card v-for="file in files" :key="file.id" class="file-card" shadow="hover">
<el-image :src="file.url" fit="cover" class="file-thumbnail" :preview-src-list="[file.url]" />
<div class="file-info">
<span class="file-name" :title="file.originalName">{{ file.originalName }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<el-popconfirm title="确定要删除此文件吗?" @confirm="handleDelete(file)">
<template #reference>
<el-button link type="danger" icon="Delete" class="delete-btn" />
</template>
</el-popconfirm>
</div>
</el-card>
</div>
</el-scrollbar>
<!-- 分页 -->
<el-pagination
v-if="total > 0"
class="pagination-container"
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
</div>
</template>
```
## 4. 数据结构
```typescript
// 文件筛选条件
interface FileFilters {
filename?: string;
}
// 文件数据
interface FileItem {
id: number;
url: string; // 文件的访问 URL
originalName: string; // 原始文件名
size: number; // 文件大小 (bytes)
mimeType: string;
uploadedAt: string;
}
```
## 5. 状态管理
```typescript
// 组件内状态
const filters = ref<FileFilters>({});
const pagination = ref({ page: 1, pageSize: 20 });
// 全局状态 (Pinia Store)
const fileStore = useFileStore();
const { files, total, loading } = storeToRefs(fileStore);
```
## 6. 交互逻辑
### 6.1 数据获取
```typescript
const fetchFiles = () => {
fileStore.fetchFiles({ ...filters.value, ...pagination.value });
};
onMounted(fetchFiles);
const handleSearch = () => {
pagination.value.page = 1;
fetchFiles();
};
// ... 分页逻辑
```
### 6.2 文件操作
```typescript
const handleDelete = async (file: FileItem) => {
await fileStore.deleteFile(file.id);
ElMessage.success('文件删除成功');
fetchFiles(); // 刷新列表
};
```
### 6.3 辅助函数
```typescript
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
```
## 7. API 调用
```typescript
// api/files.ts
export const fileApi = {
getFiles: (params) => apiClient.get('/files', { params }),
deleteFile: (id: number) => apiClient.delete(`/files/${id}`),
};
// stores/file.ts
export const useFileStore = defineStore('file', {
state: () => ({
files: [] as FileItem[],
total: 0,
loading: false,
}),
actions: {
async fetchFiles(params) {
this.loading = true;
try {
const { data } = await fileApi.getFiles(params);
this.files = data.items;
this.total = data.total;
} finally {
this.loading = false;
}
},
async deleteFile(id: number) {
await fileApi.deleteFile(id);
}
}
});
```
## 8. 样式设计
```scss
.file-management-page {
.page-container {
margin-top: 24px;
}
.file-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.file-card {
.file-thumbnail {
width: 100%;
height: 150px;
display: block;
}
.file-info {
padding: 10px;
display: flex;
align-items: center;
justify-content: space-between;
.file-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
margin-right: 10px;
}
.file-size {
font-size: 12px;
color: #909399;
flex-shrink: 0;
}
.delete-btn {
margin-left: 10px;
padding: 2px;
}
}
}
.pagination-container {
margin-top: 24px;
}
}
```
## 9. 测试用例
- **集成测试**:
- 测试页面是否能正确加载并以卡片形式展示文件列表。
- 测试搜索功能是否能正确过滤文件。
- 测试分页功能。
- 测试点击图片是否能触发大图预览。
- 测试删除文件功能,并验证文件是否从列表中移除。

View File

@@ -0,0 +1,550 @@
# EMS 前端设计文档
## 1. 技术栈
- **前端框架**: Vue 3
- **构建工具**: Vite
- **状态管理**: Pinia
- **路由管理**: Vue Router
- **UI 组件库**: Element Plus
- **HTTP 客户端**: Axios
- **CSS 预处理器**: SCSS
- **类型检查**: TypeScript
- **地图组件**: Leaflet.js
- **图表库**: ECharts
- **表单验证**: Vee-Validate
- **国际化**: Vue I18n
- **测试框架**: Vitest
## 2. 架构设计
### 2.1 目录结构
```
ems-frontend/
├── public/ # 静态资源
├── src/
│ ├── api/ # API 请求模块
│ │ ├── auth.ts # 认证相关 API
│ │ ├── dashboard.ts # 仪表盘相关 API
│ │ └── ...
│ ├── assets/ # 静态资源
│ │ ├── styles/ # 全局样式
│ │ └── images/ # 图片资源
│ ├── components/ # 通用组件
│ │ ├── common/ # 基础组件
│ │ ├── layout/ # 布局组件
│ │ └── business/ # 业务组件
│ ├── composables/ # 组合式函数
│ ├── config/ # 配置文件
│ ├── constants/ # 常量定义
│ ├── directives/ # 自定义指令
│ ├── hooks/ # 自定义 Hooks
│ ├── locales/ # 国际化资源
│ ├── router/ # 路由配置
│ ├── stores/ # Pinia 状态管理
│ ├── types/ # TypeScript 类型定义
│ ├── utils/ # 工具函数
│ ├── views/ # 页面组件
│ │ ├── auth/ # 认证相关页面
│ │ ├── dashboard/ # 仪表盘页面
│ │ └── ...
│ ├── App.vue # 根组件
│ ├── main.ts # 入口文件
│ └── env.d.ts # 环境变量类型声明
├── .env # 环境变量
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── index.html # HTML 模板
├── package.json # 依赖配置
├── tsconfig.json # TypeScript 配置
└── vite.config.ts # Vite 配置
```
### 2.2 核心模块
1. **认证模块**: 处理用户登录、注册、密码重置等功能
2. **仪表盘模块**: 展示系统概览、数据统计和图表
3. **地图模块**: 展示地理信息、网格分布和污染热图
4. **任务管理模块**: 处理任务分配、查看和审核
5. **反馈管理模块**: 处理用户反馈的提交和管理
6. **人员管理模块**: 管理系统用户、角色和权限
7. **个人中心模块**: 查看和修改个人信息
## 3. 页面结构
### 3.1 基础布局
系统将采用以下几种基础布局:
1. **主布局**: 包含侧边导航栏、顶部导航栏和内容区域
2. **认证布局**: 用于登录、注册等不需要导航的页面
3. **空白布局**: 用于全屏展示的页面,如地图全屏模式
### 3.2 响应式设计
- 采用移动优先的响应式设计
- 断点设置:
- 移动端: < 768px
- 平板: 768px - 1024px
- 桌面: > 1024px
- 关键组件的响应式行为:
- 侧边栏在移动端变为可折叠抽屉
- 表格在移动端优化为卡片式布局
- 表单在移动端采用单列布局
## 4. 路由设计
### 4.1 路由结构
```javascript
const routes = [
// 认证相关路由
{
path: '/auth',
component: AuthLayout,
children: [
{ path: 'login', component: LoginView },
{ path: 'register', component: RegisterView },
{ path: 'forgot-password', component: ForgotPasswordView },
{ path: 'reset-password', component: ResetPasswordView },
],
meta: { requiresAuth: false }
},
// 主应用路由
{
path: '/',
component: MainLayout,
children: [
{ path: '', component: DashboardView, meta: { requiresAuth: true, roles: ['ADMIN', 'DECISION_MAKER'] } },
{ path: 'map', component: MapView, meta: { requiresAuth: false } },
{ path: 'tasks', component: TasksView, meta: { requiresAuth: true } },
{ path: 'feedback', component: FeedbackView, meta: { requiresAuth: true } },
{ path: 'personnel', component: PersonnelView, meta: { requiresAuth: true, roles: ['ADMIN'] } },
{ path: 'profile', component: ProfileView, meta: { requiresAuth: true } },
]
},
// 错误页面路由
{ path: '/404', component: NotFoundView },
{ path: '/403', component: ForbiddenView },
{ path: '/:pathMatch(.*)*', redirect: '/404' }
]
```
### 4.2 路由守卫
```javascript
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
// 检查路由是否需要认证
const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false);
// 检查用户角色权限
const hasRequiredRole = to.matched.every(record => {
if (!record.meta.roles) return true;
return authStore.user && record.meta.roles.includes(authStore.user.role);
});
if (requiresAuth && !authStore.isAuthenticated) {
// 未登录,重定向到登录页
next({ path: '/auth/login', query: { redirect: to.fullPath } });
} else if (requiresAuth && !hasRequiredRole) {
// 无权限访问
next({ path: '/403' });
} else {
// 正常导航
next();
}
});
```
## 5. 状态管理
### 5.1 Pinia Store 设计
```typescript
// 认证状态管理
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: localStorage.getItem('token'),
error: null,
}),
getters: {
isAuthenticated: state => !!state.token,
userRole: state => state.user?.role || null,
},
actions: {
async login(credentials) { /* 实现登录逻辑 */ },
async logout() { /* 实现登出逻辑 */ },
async fetchUserProfile() { /* 获取用户信息 */ },
}
});
// 任务状态管理
export const useTaskStore = defineStore('task', {
state: () => ({
tasks: [],
currentTask: null,
loading: false,
error: null,
}),
getters: {
tasksByStatus: state => status => state.tasks.filter(task => task.status === status),
},
actions: {
async fetchTasks(filters) { /* 获取任务列表 */ },
async fetchTaskById(id) { /* 获取任务详情 */ },
async assignTask(taskId, userId) { /* 分配任务 */ },
async submitTask(taskId, data) { /* 提交任务 */ },
}
});
// 其他模块状态管理...
```
### 5.2 持久化策略
- 使用 localStorage 存储认证 token
- 关键用户设置使用 localStorage 持久化
- 敏感数据不进行持久化,每次会话重新获取
## 6. API 请求封装
### 6.1 基础请求客户端
```typescript
// api/client.ts
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
}
});
// 请求拦截器
apiClient.interceptors.request.use(
config => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
},
error => Promise.reject(error)
);
// 响应拦截器
apiClient.interceptors.response.use(
response => response,
error => {
const authStore = useAuthStore();
// 处理 401 未授权错误
if (error.response && error.response.status === 401) {
authStore.logout();
router.push('/auth/login');
}
return Promise.reject(error);
}
);
export default apiClient;
```
### 6.2 模块化 API 封装
```typescript
// api/auth.ts
import apiClient from './client';
export const authApi = {
login: (credentials) => apiClient.post('/auth/login', credentials),
register: (userData) => apiClient.post('/auth/signup', userData),
requestPasswordReset: (email) => apiClient.post('/auth/request-password-reset', { email }),
resetPassword: (data) => apiClient.post('/auth/reset-password', data),
};
// api/tasks.ts
import apiClient from './client';
export const taskApi = {
getTasks: (params) => apiClient.get('/management/tasks', { params }),
getTaskById: (id) => apiClient.get(`/management/tasks/${id}`),
assignTask: (taskId, data) => apiClient.post(`/management/tasks/${taskId}/assign`, data),
reviewTask: (taskId, data) => apiClient.post(`/management/tasks/${taskId}/review`, data),
};
// 其他模块 API...
```
## 7. 组件设计
### 7.1 基础组件
- **AppButton**: 统一按钮样式和行为
- **AppCard**: 卡片容器组件
- **AppForm**: 表单容器组件
- **AppInput**: 输入框组件
- **AppSelect**: 下拉选择组件
- **AppTable**: 表格组件
- **AppPagination**: 分页组件
- **AppModal**: 模态框组件
- **AppAlert**: 提示框组件
### 7.2 业务组件
- **TaskCard**: 任务卡片组件
- **FeedbackForm**: 反馈提交表单
- **UserSelector**: 用户选择器
- **StatusBadge**: 状态标签组件
- **MapGrid**: 地图网格组件
- **PollutionTypeIcon**: 污染类型图标
- **TaskFilterPanel**: 任务筛选面板
- **StatisticCard**: 统计数据卡片
## 8. 主题设计
### 8.1 色彩系统
- **主色**: #1890ff (蓝色)
- **成功色**: #52c41a (绿色)
- **警告色**: #faad14 (黄色)
- **错误色**: #f5222d (红色)
- **中性色**:
- 标题: #262626
- 正文: #595959
- 辅助文字: #8c8c8c
- 禁用: #bfbfbf
- 边框: #d9d9d9
- 分割线: #f0f0f0
- 背景: #f5f5f5
- 表格头部: #fafafa
### 8.2 字体系统
- **主字体**: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Helvetica Neue', Arial, sans-serif
- **代码字体**: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace
- **字体大小**:
- 小号: 12px
- 正文: 14px
- 标题: 16px, 18px, 20px, 24px
### 8.3 间距系统
- **基础单位**: 4px
- **间距尺寸**:
- xs: 4px
- sm: 8px
- md: 16px
- lg: 24px
- xl: 32px
- xxl: 48px
### 8.4 阴影系统
- **轻微阴影**: 0 2px 8px rgba(0, 0, 0, 0.15)
- **中等阴影**: 0 4px 12px rgba(0, 0, 0, 0.15)
- **深度阴影**: 0 8px 16px rgba(0, 0, 0, 0.15)
## 9. 国际化
支持中文和英文两种语言,使用 Vue I18n 实现国际化。
```typescript
// locales/zh-CN.json
{
"auth": {
"login": "登录",
"register": "注册",
"forgotPassword": "忘记密码",
"email": "邮箱",
"password": "密码",
"confirmPassword": "确认密码"
},
"dashboard": {
"title": "仪表盘",
"stats": "统计数据",
"tasks": "任务概览",
"feedback": "反馈概览"
}
}
// locales/en-US.json
{
"auth": {
"login": "Login",
"register": "Register",
"forgotPassword": "Forgot Password",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password"
},
"dashboard": {
"title": "Dashboard",
"stats": "Statistics",
"tasks": "Task Overview",
"feedback": "Feedback Overview"
}
}
```
## 10. 权限控制
### 10.1 角色定义
- **ADMIN**: 系统管理员,拥有所有权限
- **SUPERVISOR**: 主管,负责任务分配和审核
- **GRID_WORKER**: 网格员,负责执行任务
- **DECISION_MAKER**: 决策者,查看数据和报表
### 10.2 权限指令
```typescript
// 创建自定义指令控制元素的显示/隐藏
app.directive('permission', {
mounted(el, binding) {
const { value } = binding;
const authStore = useAuthStore();
if (value && !authStore.hasPermission(value)) {
el.parentNode && el.parentNode.removeChild(el);
}
}
});
// 使用示例
<button v-permission="'ADMIN'"></button>
<div v-permission="['ADMIN', 'SUPERVISOR']"></div>
```
## 11. 错误处理
### 11.1 全局错误处理
```typescript
// 全局错误处理
app.config.errorHandler = (err, vm, info) => {
console.error('全局错误:', err);
// 上报错误到监控系统
errorReportingService.report(err, {
component: vm?.$options?.name,
info,
user: useAuthStore().user?.id
});
// 显示用户友好的错误提示
ElMessage.error('操作失败,请稍后重试');
};
```
### 11.2 API 错误处理
```typescript
// 统一处理 API 错误
export const handleApiError = (error) => {
if (error.response) {
// 服务器响应错误
const { status, data } = error.response;
switch (status) {
case 400:
ElMessage.error(data.message || '请求参数错误');
break;
case 401:
ElMessage.error('会话已过期,请重新登录');
break;
case 403:
ElMessage.error('没有权限执行此操作');
break;
case 404:
ElMessage.error('请求的资源不存在');
break;
case 500:
ElMessage.error('服务器内部错误,请稍后重试');
break;
default:
ElMessage.error('请求失败,请稍后重试');
}
} else if (error.request) {
// 请求发出但没有收到响应
ElMessage.error('网络连接失败,请检查网络设置');
} else {
// 请求配置错误
ElMessage.error('请求配置错误');
}
return Promise.reject(error);
};
```
## 12. 性能优化
### 12.1 代码分割
- 使用动态导入实现路由级别的代码分割
- 将大型第三方库单独打包
### 12.2 资源优化
- 图片资源使用 WebP 格式并进行压缩
- 使用 SVG 图标代替图片图标
- 使用字体图标减少 HTTP 请求
### 12.3 渲染优化
- 使用虚拟滚动处理长列表
- 合理使用 `v-show``v-if`
- 大型表格使用分页加载
- 使用 `keep-alive` 缓存组件状态
## 13. 测试策略
### 13.1 单元测试
- 使用 Vitest 进行单元测试
- 重点测试工具函数、Hooks 和小型组件
- 使用 Vue Test Utils 测试组件
### 13.2 端到端测试
- 使用 Cypress 进行端到端测试
- 覆盖关键用户流程:
- 用户注册和登录
- 任务创建和分配
- 反馈提交和处理
## 14. 部署策略
### 14.1 构建配置
- 开发环境: `vite build --mode development`
- 测试环境: `vite build --mode staging`
- 生产环境: `vite build --mode production`
### 14.2 环境变量
```
# .env.development
VITE_API_BASE_URL=http://localhost:8080/api
VITE_APP_TITLE=EMS开发环境
# .env.production
VITE_API_BASE_URL=/api
VITE_APP_TITLE=环境监测系统
```
### 14.3 CI/CD 流程
- 使用 GitHub Actions 自动化构建和部署
- 提交到 `develop` 分支自动部署到测试环境
- 提交到 `main` 分支自动部署到生产环境

View File

@@ -0,0 +1,232 @@
# 网格管理页面设计文档
## 1. 页面概述
网格管理页面允许系统管理员创建、查看、编辑和删除地理网格。每个网格代表一个责任区域,可以关联到特定的主管或网格员。此功能是任务分配和地理数据可视化的基础。
## 2. 页面布局
![网格管理页面布局示意图](https://placeholder-for-grid-page-mockup.png)
### 2.1 布局结构
页面由两部分组成:
- **左侧**: 网格列表,以表格形式展示所有已定义的网格及其基本信息(名称、负责人等)。
- **右侧**: 交互式地图,用于可视化展示所选网格的地理边界。当创建或编辑网格时,地图将进入绘图模式。
## 3. 组件结构
```vue
<template>
<div class="grid-management-page">
<el-page-header title="网格管理" content="定义地理责任区" />
<el-row :gutter="20" class="page-container">
<!-- 左侧网格列表与操作 -->
<el-col :span="10" :xs="24">
<el-card>
<template #header>
<div class="table-toolbar">
<span>网格列表</span>
<el-button type="primary" icon="Plus" @click="handleCreate">新建网格</el-button>
</div>
</template>
<el-table :data="grids" v-loading="loading" @row-click="handleRowClick" highlight-current-row>
<el-table-column prop="name" label="网格名称" />
<el-table-column prop="manager" label="负责人" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button link type="primary" @click.stop="handleEdit(row)">编辑</el-button>
<el-popconfirm title="确定删除此网格?" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger" @click.stop>删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<!-- 右侧地图显示 -->
<el-col :span="14" :xs="24">
<el-card>
<template #header>地图预览</template>
<div ref="mapContainer" class="map-container"></div>
</el-card>
</el-col>
</el-row>
<!-- 网格编辑/创建对话框 -->
<GridFormDialog v-model="dialogVisible" :grid-id="selectedGridId" @success="onFormSuccess" />
</div>
</template>
```
## 4. 数据结构
```typescript
// 网格列表项
interface GridListItem {
id: number;
name: string;
manager?: string;
// GeoJSON 字符串
geometry: string;
}
// 网格表单数据 (用于对话框)
interface GridFormData {
id?: number;
name: string;
managerId?: number;
// GeoJSON 字符串
geometry: string;
}
```
## 5. 状态管理
```typescript
// 地图实例
let mapInstance = null;
let currentGridLayer = null;
// 组件内状态
const mapContainer = ref<HTMLElement>();
const dialogVisible = ref(false);
const selectedGridId = ref<number | null>(null);
// 全局状态 (Pinia Store)
const gridStore = useGridStore();
const { grids, loading } = storeToRefs(gridStore);
```
## 6. 交互逻辑
### 6.1 地图与列表交互
```typescript
onMounted(() => {
gridStore.fetchGrids();
initializeMap();
});
const initializeMap = () => {
// ... 地图初始化逻辑
};
// 点击表格行,在地图上高亮显示对应网格
const handleRowClick = (row: GridListItem) => {
if (currentGridLayer) {
mapInstance.removeLayer(currentGridLayer);
}
if (row.geometry) {
const geoJson = JSON.parse(row.geometry);
currentGridLayer = L.geoJSON(geoJson).addTo(mapInstance);
mapInstance.fitBounds(currentGridLayer.getBounds());
}
};
```
### 6.2 CRUD 操作
```typescript
const handleCreate = () => {
selectedGridId.value = null;
dialogVisible.value = true;
};
const handleEdit = (grid: GridListItem) => {
selectedGridId.value = grid.id;
dialogVisible.value = true;
};
const handleDelete = async (grid: GridListItem) => {
await gridStore.deleteGrid(grid.id);
ElMessage.success('网格删除成功');
gridStore.fetchGrids();
};
const onFormSuccess = () => {
dialogVisible.value = false;
gridStore.fetchGrids();
};
```
## 7. API 调用
```typescript
// api/grid.ts
export const gridApi = {
getGrids: () => apiClient.get('/grid'),
getGridById: (id: number) => apiClient.get(`/grid/${id}`),
createGrid: (data: GridFormData) => apiClient.post('/grid', data),
updateGrid: (id: number, data: GridFormData) => apiClient.put(`/grid/${id}`, data),
deleteGrid: (id: number) => apiClient.delete(`/grid/${id}`),
};
// stores/grid.ts
export const useGridStore = defineStore('grid', {
state: () => ({
grids: [] as GridListItem[],
loading: false,
}),
actions: {
async fetchGrids() {
this.loading = true;
try {
const { data } = await gridApi.getGrids();
this.grids = data;
} finally {
this.loading = false;
}
},
// ... create, update, delete actions
}
});
```
## 8. 样式设计
```scss
.grid-management-page {
padding: 24px;
.page-container {
margin-top: 24px;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
}
.map-container {
height: 600px;
}
}
```
## 9. 关联组件
### `GridFormDialog.vue`
这是本页面的核心复杂组件,用于创建和编辑网格。
- **Props**: `modelValue`, `gridId`
- **内部组件**:
- 一个表单,包含网格名称、负责人(下拉选择)等字段。
- 一个内嵌的 Leaflet 地图,用于绘制和编辑网格边界。
- **功能**:
- **地图绘制**: 提供多边形Polygon绘制工具。用户可以在地图上绘制闭合区域来定义网格边界。
- **数据加载**: 在编辑模式下,根据 `gridId` 加载网格数据,并在表单和内嵌地图上显示。
- **数据保存**: 用户完成绘制和表单填写后,点击保存。组件将绘制的多边形转换为 GeoJSON 字符串,连同表单数据一起,调用 store 的 action 进行保存。
## 10. 测试用例
- **集成测试**:
- 测试能否成功创建一个新网格,包括在地图上绘制边界和填写信息。
- 测试点击列表中的网格,地图上是否正确显示其边界。
- 测试编辑功能,包括修改网格信息和在地图上重新编辑边界。
- 测试删除网格功能。

View File

@@ -0,0 +1,436 @@
# 登录页面设计文档
## 1. 页面概述
登录页面是用户进入系统的主要入口,提供账号密码登录功能,同时支持记住登录状态和找回密码功能。
## 2. 页面布局
![登录页面布局示意图](https://placeholder-for-login-page-mockup.png)
### 2.1 布局结构
登录页面采用居中卡片式布局,包含以下主要区域:
- 顶部 Logo 和系统名称区域
- 中部登录表单区域
- 底部版权信息和帮助链接区域
### 2.2 响应式设计
- **桌面端**:居中卡片,宽度 400px两侧留白
- **平板端**:居中卡片,宽度 80%,两侧留白
- **移动端**:全宽卡片,上下留白,左右边距 16px
## 3. 组件结构
```vue
<template>
<div class="login-container">
<!-- 顶部 Logo 和系统名称 -->
<div class="login-header">
<img src="@/assets/images/logo.svg" alt="系统 Logo" class="login-logo" />
<h1 class="login-title">环境监测系统</h1>
</div>
<!-- 登录表单 -->
<el-card class="login-card">
<h2 class="login-card-title">用户登录</h2>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
>
<!-- 用户名输入框 -->
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
prefix-icon="User"
/>
</el-form-item>
<!-- 密码输入框 -->
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<!-- 记住我和忘记密码 -->
<div class="login-options">
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
<el-link type="primary" @click="forgotPassword">忘记密码</el-link>
</div>
<!-- 登录按钮 -->
<el-button
type="primary"
class="login-button"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form>
<!-- 其他登录方式 -->
<div v-if="enableOtherLoginMethods" class="other-login-methods">
<div class="divider">
<span>其他登录方式</span>
</div>
<div class="login-icons">
<el-button circle icon="Wechat" class="icon-button wechat" />
</div>
</div>
<!-- 注册链接 -->
<div class="register-link">
<span>还没有账号</span>
<el-link type="primary" @click="goToRegister">立即注册</el-link>
</div>
</el-card>
<!-- 底部版权信息 -->
<div class="login-footer">
<p>© 2025 环境监测系统 版权所有</p>
<p>
<el-link type="info" href="#">用户协议</el-link>
<el-divider direction="vertical" />
<el-link type="info" href="#">隐私政策</el-link>
</p>
</div>
</div>
</template>
```
## 4. 数据结构
```typescript
interface LoginForm {
username: string;
password: string;
remember: boolean;
}
interface LoginResponse {
token: string;
user: {
id: number;
username: string;
role: string;
permissions: string[];
};
}
```
## 5. 状态管理
```typescript
// 组件内状态
const loginForm = ref<LoginForm>({
username: '',
password: '',
remember: false
});
const loading = ref<boolean>(false);
const loginFormRef = ref<FormInstance>();
const enableOtherLoginMethods = ref<boolean>(true);
// 全局状态 (Pinia Store)
const authStore = useAuthStore();
```
## 6. 交互逻辑
### 6.1 表单验证规则
```typescript
const loginRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度应为 3-20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度应为 6-20 个字符', trigger: 'blur' }
]
};
```
### 6.2 登录流程
```typescript
const handleLogin = async () => {
if (!loginFormRef.value) return;
await loginFormRef.value.validate(async (valid) => {
if (!valid) return;
try {
loading.value = true;
// 调用登录 API
await authStore.login({
username: loginForm.value.username,
password: loginForm.value.password
});
// 如果选择记住我,设置本地存储
if (loginForm.value.remember) {
localStorage.setItem('remember_username', loginForm.value.username);
} else {
localStorage.removeItem('remember_username');
}
// 登录成功提示
ElMessage.success('登录成功');
// 获取重定向地址或跳转到首页
const redirect = route.query.redirect as string || '/';
router.push(redirect);
} catch (error) {
// 错误处理
ElMessage.error(error.response?.data?.message || '登录失败,请检查用户名和密码');
} finally {
loading.value = false;
}
});
};
```
### 6.3 其他交互功能
```typescript
// 跳转到注册页面
const goToRegister = () => {
router.push('/auth/register');
};
// 跳转到忘记密码页面
const forgotPassword = () => {
router.push('/auth/forgot-password');
};
// 生命周期钩子,检查是否有记住的用户名
onMounted(() => {
const rememberedUsername = localStorage.getItem('remember_username');
if (rememberedUsername) {
loginForm.value.username = rememberedUsername;
loginForm.value.remember = true;
}
});
```
## 7. API 调用
### 7.1 登录 API
```typescript
// api/auth.ts
export const authApi = {
login: (credentials: { username: string; password: string }) =>
apiClient.post<LoginResponse>('/auth/login', credentials)
};
// stores/auth.ts
export const useAuthStore = defineStore('auth', {
// ... 其他状态和 getters
actions: {
async login(credentials) {
try {
const { data } = await authApi.login(credentials);
this.token = data.token;
this.user = data.user;
// 存储 token 到本地存储
localStorage.setItem('token', data.token);
return data;
} catch (error) {
this.error = error.response?.data?.message || '登录失败';
throw error;
}
}
}
});
```
## 8. 样式设计
```scss
.login-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f7fa;
padding: 20px;
.login-header {
text-align: center;
margin-bottom: 30px;
.login-logo {
width: 80px;
height: 80px;
}
.login-title {
font-size: 24px;
color: #303133;
margin-top: 16px;
}
}
.login-card {
width: 400px;
max-width: 100%;
.login-card-title {
font-size: 20px;
text-align: center;
margin-bottom: 30px;
font-weight: 500;
}
.login-form {
margin-bottom: 20px;
.login-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.login-button {
width: 100%;
height: 40px;
font-size: 16px;
}
}
.other-login-methods {
margin-top: 24px;
.divider {
display: flex;
align-items: center;
margin: 16px 0;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background-color: #dcdfe6;
}
span {
padding: 0 16px;
color: #909399;
font-size: 14px;
}
}
.login-icons {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 16px;
.icon-button {
font-size: 20px;
&.wechat {
color: #07c160;
background-color: #f0f9eb;
border-color: #e1f3d8;
&:hover {
background-color: #e1f3d8;
}
}
}
}
}
.register-link {
text-align: center;
margin-top: 24px;
font-size: 14px;
}
}
.login-footer {
margin-top: 40px;
text-align: center;
color: #909399;
font-size: 12px;
p {
margin: 8px 0;
}
}
}
// 响应式样式
@media screen and (max-width: 768px) {
.login-container {
padding: 16px;
.login-card {
width: 100%;
}
}
}
```
## 9. 安全考虑
1. **密码安全**
- 密码输入框使用 `type="password"``show-password` 属性
- 密码不在前端存储,只在登录时传输
- 密码传输使用 HTTPS 加密
2. **Token 安全**
- Token 存储在 localStorage 中,有 XSS 风险,可考虑使用 HttpOnly Cookie
- Token 过期处理在响应拦截器中统一处理
3. **表单安全**
- 实施前端输入验证,防止基本的注入攻击
- 防止表单重复提交(登录按钮 loading 状态)
## 10. 测试用例
1. **单元测试**
- 测试表单验证逻辑
- 测试记住用户名功能
2. **集成测试**
- 测试登录成功流程
- 测试登录失败处理
- 测试重定向功能
3. **端到端测试**
- 测试完整登录流程
- 测试页面响应式布局
## 11. 性能优化
1. **延迟加载**
- 第三方图标库按需引入
- 忘记密码页面使用动态导入
2. **缓存策略**
- 记住用户名使用 localStorage
- 静态资源logo 等)设置适当的缓存策略

View File

@@ -0,0 +1,296 @@
# 地图页面设计文档
## 1. 页面概述
地图页面是 EMS 系统的核心可视化界面,提供基于地理位置的直观信息展示。它主要用于显示城市的环境网格划分、实时污染数据(如热力图)、网格员位置以及特定任务的地理标记。此页面帮助主管和决策者监控区域状态,并为网格员提供清晰的工作区域指引。
## 2. 页面布局
![地图页面布局示意图](https://placeholder-for-map-page-mockup.png)
### 2.1 布局结构
页面采用全屏地图为主体的布局,辅以侧边栏和浮动控件。
- **主区域**: 全屏交互式地图,作为所有地理信息的载体。
- **左侧边栏**: 用于控制地图上显示的图层,如网格边界、热力图、网格员位置等。同时,也可能包含一个可搜索的兴趣点列表。
- **地图控件**: 地图右上角或左上角放置缩放、定位、全屏等标准地图控件。
- **信息弹窗 (Popup)**: 点击地图上的特定元素(如网格、标记点)时,会弹出信息窗口,显示该元素的详细信息。
### 2.2 响应式设计
- **桌面端**: 显示完整的左侧边栏和地图。
- **平板端/移动端**: 左侧边栏默认收起,可通过按钮展开为抽屉或浮动面板,以最大化地图可视区域。
## 3. 组件结构
```vue
<template>
<div class="map-page">
<!-- 地图容器 -->
<div ref="mapContainer" class="map-container"></div>
<!-- 左侧图层控制面板 -->
<el-card class="layer-control-panel">
<template #header>
<div class="panel-header">
<span>图层控制</span>
<el-button link type="primary" @click="togglePanel" class="panel-toggle-btn">
<el-icon><ArrowLeft v-if="isPanelVisible" /><ArrowRight v-else /></el-icon>
</el-button>
</div>
</template>
<div v-show="isPanelVisible">
<el-checkbox-group v-model="visibleLayers">
<el-checkbox label="grids">显示网格</el-checkbox>
<el-checkbox label="heatmap">显示污染热力图</el-checkbox>
<el-checkbox label="workers">显示网格员位置</el-checkbox>
<el-checkbox label="tasks">显示任务标记</el-checkbox>
</el-checkbox-group>
<el-divider />
<h4>搜索网格</h4>
<el-input v-model="gridSearch" placeholder="输入网格名称/编号搜索" @keyup.enter="searchGrid" />
</div>
</el-card>
<!-- 地图加载状态 -->
<div v-if="mapLoading" class="map-loading-overlay">
<el-icon class="is-loading" size="30"><Loading /></el-icon>
<span>地图加载中...</span>
</div>
</div>
</template>
```
## 4. 数据结构
```typescript
// 网格数据结构
interface GridData {
id: number;
name: string;
// GeoJSON 格式的多边形坐标
geometry: {
type: 'Polygon';
coordinates: number[][][];
};
manager: string; // 网格负责人
}
// 热力图数据点
interface HeatmapPoint {
lat: number;
lng: number;
value: number; // 污染指数
}
// 网格员位置
interface WorkerPosition {
id: number;
name: string;
lat: number;
lng: number;
lastUpdated: string;
}
// 任务标记
interface TaskMarker {
id: number;
title: string;
lat: number;
lng: number;
status: string;
}
```
## 5. 状态管理
```typescript
// 地图服务实例
let mapInstance = null;
let gridLayerGroup = null;
let heatmapLayer = null;
let workerMarkersGroup = null;
// 组件内状态
const mapContainer = ref<HTMLElement | null>(null);
const mapLoading = ref<boolean>(true);
const isPanelVisible = ref<boolean>(true);
const visibleLayers = ref<string[]>(['grids']); // 默认显示的图层
const gridSearch = ref<string>('');
// 全局状态 (Pinia Store)
const mapStore = useMapStore();
const { grids, heatmapData, workers } = storeToRefs(mapStore);
// 监听可见图层变化,动态更新地图
watch(visibleLayers, (newLayers, oldLayers) => {
// ... 调用地图服务方法显示/隐藏图层
});
```
## 6. 交互逻辑
### 6.1 地图初始化
```typescript
onMounted(() => {
initializeMap();
mapStore.fetchInitialMapData();
});
const initializeMap = () => {
if (mapContainer.value) {
mapInstance = L.map(mapContainer.value).setView([39.9042, 116.4074], 10); // 默认北京
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(mapInstance);
mapLoading.value = false;
}
};
```
### 6.2 图层控制
```typescript
const updateLayers = () => {
// 网格图层
if (visibleLayers.value.includes('grids') && !mapInstance.hasLayer(gridLayerGroup)) {
gridLayerGroup.addTo(mapInstance);
} else if (!visibleLayers.value.includes('grids') && mapInstance.hasLayer(gridLayerGroup)) {
gridLayerGroup.removeFrom(mapInstance);
}
// 其他图层同理...
};
// 监听 Pinia store 中数据的变化,并更新地图
watch(grids, (newGrids) => {
if (gridLayerGroup) {
gridLayerGroup.clearLayers();
// 添加新的网格到图层
newGrids.forEach(grid => {
const polygon = L.geoJSON(grid.geometry);
polygon.bindPopup(`<b>${grid.name}</b><br>负责人: ${grid.manager}`);
gridLayerGroup.addLayer(polygon);
});
}
});
```
### 6.3 面板交互
```typescript
const togglePanel = () => {
isPanelVisible.value = !isPanelVisible.value;
};
const searchGrid = () => {
const targetGrid = grids.value.find(g => g.name === gridSearch.value);
if (targetGrid) {
// 飞到目标网格位置
const bounds = L.geoJSON(targetGrid.geometry).getBounds();
mapInstance.flyToBounds(bounds);
} else {
ElMessage.info('未找到该网格');
}
};
```
## 7. API 调用
```typescript
// api/map.ts
export const mapApi = {
getGrids: () => apiClient.get<GridData[]>('/map/grids'),
getHeatmapData: () => apiClient.get<HeatmapPoint[]>('/map/heatmap'),
getWorkerPositions: () => apiClient.get<WorkerPosition[]>('/map/workers'),
};
// stores/map.ts
export const useMapStore = defineStore('map', {
state: () => ({
grids: [] as GridData[],
heatmapData: [] as HeatmapPoint[],
workers: [] as WorkerPosition[],
}),
actions: {
async fetchInitialMapData() {
// 并发请求所有地图数据
const [gridsRes, heatmapRes, workersRes] = await Promise.all([
mapApi.getGrids(),
mapApi.getHeatmapData(),
mapApi.getWorkerPositions(),
]);
this.grids = gridsRes.data;
this.heatmapData = heatmapRes.data;
this.workers = workersRes.data;
}
}
});
```
## 8. 样式设计
```scss
.map-page {
position: relative;
width: 100%;
height: calc(100vh - 50px); /* 减去顶部导航栏高度 */
.map-container {
width: 100%;
height: 100%;
z-index: 1;
}
.layer-control-panel {
position: absolute;
top: 20px;
left: 20px;
z-index: 2;
width: 250px;
background: white;
transition: transform 0.3s ease;
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.map-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 3;
flex-direction: column;
gap: 10px;
}
}
```
## 9. 测试用例
1. **单元测试**:
- 测试 Pinia store 的 action 是否能正确获取和存储地图数据。
- 测试地图交互函数,如 `searchGrid` 的逻辑。
2. **集成测试**:
- 验证地图是否能成功初始化并显示底图。
- 验证图层控制复选框是否能正确显示/隐藏对应的地理要素。
- 验证点击网格是否能弹出正确的信息窗口。
- 验证搜索功能是否能正确地将地图视图定位到指定网格。
## 10. 性能优化
1. **大数据渲染**:
- 对于大量的标记点(如网格员、任务),使用 `L.markerClusterGroup` 插件进行聚合,提高性能。
- 对于复杂的网格边界Polygon在低缩放级别下使用简化的几何图形可通过后端服务实现
2. **懒加载**: 地图库Leaflet和相关插件热力图、聚合应按需异步加载。
3. **数据更新**: 网格员位置等实时数据应使用 WebSocket 或定时轮询(配合 `requestAnimationFrame`)进行高效更新,而不是频繁重绘整个图层。

View File

@@ -0,0 +1,296 @@
# 人员管理页面设计文档
## 1. 页面概述
人员管理页面是提供给系统管理员ADMIN用于管理所有用户账户的界面。其核心功能包括展示用户列表、添加新用户、编辑现有用户信息、更改用户角色与状态以及搜索和筛选用户。
## 2. 页面布局
![人员管理页面布局示意图](https://placeholder-for-personnel-page-mockup.png)
### 2.1 布局结构
页面采用经典的后台管理布局,与任务管理、反馈管理页面保持一致:
- **顶部**: 操作区域,包含"添加用户"按钮。
- **中部**: 筛选和搜索区域,支持按用户名、角色或状态进行搜索。
- **下部**: 用户数据表格,展示所有用户及其关键信息,并提供行内操作。
- **底部**: 分页组件。
## 3. 组件结构
```vue
<template>
<div class="personnel-management-page">
<el-page-header title="人员管理" content="系统用户列表" />
<el-card class="page-container">
<!-- 搜索与操作区域 -->
<div class="table-toolbar">
<el-form :model="filters" inline class="filter-form">
<el-form-item>
<el-input v-model="filters.name" placeholder="按姓名或用户名搜索" clearable />
</el-form-item>
<el-form-item>
<el-select v-model="filters.role" placeholder="按角色筛选" clearable>
<el-option label="管理员" value="ADMIN" />
<el-option label="主管" value="SUPERVISOR" />
<el-option label="网格员" value="GRID_WORKER" />
<el-option label="决策者" value="DECISION_MAKER" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
<div class="action-buttons">
<el-button type="primary" icon="Plus" @click="handleCreateUser">添加用户</el-button>
</div>
</div>
<!-- 用户表格 -->
<el-table :data="users" v-loading="loading" stripe>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="role" label="角色">
<template #default="{ row }">
<span>{{ formatRole(row.role) }}</span>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-switch
v-model="row.isActive"
@change="handleStatusChange(row)"
:loading="row.statusChanging"
active-text="启用"
inactive-text="禁用"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-popconfirm title="确定要删除该用户吗?" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-if="total > 0"
class="pagination-container"
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
<!-- 用户表单对话框 -->
<UserFormDialog
v-model="dialogVisible"
:user-id="selectedUserId"
@success="onFormSuccess"
/>
</div>
</template>
```
## 4. 数据结构
```typescript
// 用户筛选条件
interface UserFilters {
name?: string;
role?: 'ADMIN' | 'SUPERVISOR' | 'GRID_WORKER' | 'DECISION_MAKER';
}
// 用户列表项
interface UserListItem {
id: number;
username: string;
name: string;
email: string;
role: string;
isActive: boolean;
statusChanging?: boolean; // 用于控制 Switch 的 loading 状态
}
// 用户表单数据 (用于对话框)
interface UserFormData {
id?: number;
username: string;
name: string;
email: string;
role: string;
password?: string; // 创建时需要,编辑时可选
}
```
## 5. 状态管理
```typescript
// 组件内状态
const filters = ref<UserFilters>({});
const pagination = ref({ page: 1, pageSize: 10 });
const dialogVisible = ref(false);
const selectedUserId = ref<number | null>(null);
// 全局状态 (Pinia Store)
const personnelStore = usePersonnelStore();
const { users, total, loading } = storeToRefs(personnelStore);
```
## 6. 交互逻辑
### 6.1 CRUD 操作
```typescript
const fetchUsers = () => {
personnelStore.fetchUsers({ ...filters.value, ...pagination.value });
};
onMounted(fetchUsers);
const handleSearch = () => {
pagination.value.page = 1;
fetchUsers();
};
const handleCreateUser = () => {
selectedUserId.value = null;
dialogVisible.value = true;
};
const handleEdit = (user: UserListItem) => {
selectedUserId.value = user.id;
dialogVisible.value = true;
};
const handleDelete = async (user: UserListItem) => {
await personnelStore.deleteUser(user.id);
ElMessage.success('用户删除成功');
fetchUsers(); // 刷新列表
};
const handleStatusChange = async (user: UserListItem) => {
user.statusChanging = true;
try {
await personnelStore.updateUserStatus(user.id, user.isActive);
ElMessage.success('状态更新成功');
} catch {
// 失败时将开关拨回原位
user.isActive = !user.isActive;
} finally {
user.statusChanging = false;
}
};
const onFormSuccess = () => {
dialogVisible.value = false;
fetchUsers();
};
```
### 6.2 辅助函数
```typescript
const formatRole = (role: string) => {
const roleMap = {
ADMIN: '管理员',
SUPERVISOR: '主管',
GRID_WORKER: '网格员',
DECISION_MAKER: '决策者'
};
return roleMap[role] || '未知角色';
};
```
## 7. API 调用
```typescript
// api/personnel.ts
export const personnelApi = {
getUsers: (params) => apiClient.get('/personnel', { params }),
getUserById: (id: number) => apiClient.get(`/personnel/${id}`),
createUser: (data: UserFormData) => apiClient.post('/personnel', data),
updateUser: (id: number, data: UserFormData) => apiClient.put(`/personnel/${id}`, data),
deleteUser: (id: number) => apiClient.delete(`/personnel/${id}`),
};
// stores/personnel.ts
export const usePersonnelStore = defineStore('personnel', {
state: () => ({
users: [] as UserListItem[],
total: 0,
loading: false,
}),
actions: {
async fetchUsers(params) {
this.loading = true;
try {
const { data } = await personnelApi.getUsers(params);
this.users = data.items.map(u => ({ ...u, statusChanging: false }));
this.total = data.total;
} finally {
this.loading = false;
}
},
// ...其他 createUser, updateUser, deleteUser 等 actions
}
});
```
## 8. 样式设计
```scss
.personnel-management-page {
padding: 24px;
.page-container {
margin-top: 24px;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.pagination-container {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
}
```
## 9. 关联组件
### `UserFormDialog.vue`
用于创建和编辑用户的对话框组件。
- **Props**: `modelValue`, `userId`
- **功能**:
- 如果 `userId` 存在,则为编辑模式,对话框打开时会根据 ID 加载用户信息。
- 如果 `userId` 为空,则为创建模式。
- 包含用户名、姓名、邮箱、角色和密码的表单字段。
- 密码字段在编辑模式下为可选,并提示"留空则不修改密码"。
- 表单提交时进行验证,并调用 store 中对应的 `createUser``updateUser` action。
- 操作成功后,发出 `success` 事件。
## 10. 测试用例
- **集成测试**:
- 测试能否成功添加一个新用户。
- 测试能否成功编辑一个现有用户的信息(包括修改密码和不修改密码两种情况)。
- 测试能否成功删除一个用户。
- 测试启用/禁用开关是否能正确更新用户状态。
- 测试搜索和筛选功能是否能正确过滤用户列表。

View File

@@ -0,0 +1,258 @@
# 个人中心页面设计文档
## 1. 页面概述
个人中心页面允许当前登录的用户查看和修改自己的个人信息,以及更改密码。此页面旨在提供一个简单、安全的界面来管理个人账户资料。
## 2. 页面布局
![个人中心页面布局示意图](https://placeholder-for-profile-page-mockup.png)
### 2.1 布局结构
页面通常采用多标签页Tabs布局将不同功能的表单分开以保持界面整洁。
- **左侧**: 用户头像和基本信息展示。
- **右侧**: 包含两个标签页:
- **基本资料**: 用于修改用户名、姓名、邮箱等信息。
- **修改密码**: 用于更改当前用户的登录密码。
## 3. 组件结构
```vue
<template>
<div class="profile-page">
<el-page-header title="个人中心" content="管理您的账户信息" />
<el-row :gutter="20" class="page-container">
<el-col :span="8" :xs="24">
<el-card class="user-card">
<div class="user-avatar">
<el-avatar :size="100" :src="user.avatarUrl">{{ user.name?.[0] }}</el-avatar>
</div>
<div class="user-info">
<h2>{{ user.name }}</h2>
<p>{{ user.role }}</p>
</div>
</el-card>
</el-col>
<el-col :span="16" :xs="24">
<el-card>
<el-tabs v-model="activeTab">
<!-- 基本资料标签页 -->
<el-tab-pane label="基本资料" name="profile">
<el-form :model="profileForm" :rules="profileRules" ref="profileFormRef" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="profileForm.username" disabled />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="profileForm.name" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="profileForm.email" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="updateProfile" :loading="profileLoading">
保存更改
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 修改密码标签页 -->
<el-tab-pane label="修改密码" name="password">
<el-form :model="passwordForm" :rules="passwordRules" ref="passwordFormRef" label-width="80px">
<el-form-item label="旧密码" prop="oldPassword">
<el-input type="password" v-model="passwordForm.oldPassword" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input type="password" v-model="passwordForm.newPassword" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input type="password" v-model="passwordForm.confirmPassword" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="changePassword" :loading="passwordLoading">
确认修改
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
</div>
</template>
```
## 4. 数据结构
```typescript
// 用户信息 (来自 Store)
interface UserProfile {
id: number;
username: string;
name: string;
email: string;
role: string;
avatarUrl?: string;
}
// 修改密码表单
interface PasswordForm {
oldPassword: string;
newPassword: string;
confirmPassword: string;
}
```
## 5. 状态管理
```typescript
// 组件内状态
const activeTab = ref('profile');
const profileForm = ref<Partial<UserProfile>>({});
const passwordForm = ref<PasswordForm>({ oldPassword: '', newPassword: '', confirmPassword: '' });
const profileLoading = ref(false);
const passwordLoading = ref(false);
const profileFormRef = ref<FormInstance>();
const passwordFormRef = ref<FormInstance>();
// 全局状态 (Pinia Store)
const authStore = useAuthStore();
const { user } = storeToRefs(authStore);
// 当 user 数据从 store 加载后,填充表单
watch(user, (currentUser) => {
if (currentUser) {
profileForm.value = { ...currentUser };
}
}, { immediate: true });
```
## 6. 交互逻辑
### 6.1 更新个人资料
```typescript
const updateProfile = async () => {
await profileFormRef.value?.validate(async (valid) => {
if (!valid) return;
profileLoading.value = true;
try {
await authStore.updateProfile({
name: profileForm.value.name,
email: profileForm.value.email,
});
ElMessage.success('个人资料更新成功');
} finally {
profileLoading.value = false;
}
});
};
```
### 6.2 修改密码
```typescript
const changePassword = async () => {
await passwordFormRef.value?.validate(async (valid) => {
if (!valid) return;
passwordLoading.value = true;
try {
await authStore.changePassword(passwordForm.value);
ElMessage.success('密码修改成功,请重新登录');
// 密码修改成功后通常需要用户重新登录
authStore.logout();
router.push('/auth/login');
} catch (error) {
ElMessage.error(error.response?.data?.message || '密码修改失败');
} finally {
passwordLoading.value = false;
}
});
};
// 自定义密码验证规则
const validateConfirmPassword = (rule: any, value: any, callback: any) => {
if (value !== passwordForm.value.newPassword) {
callback(new Error('两次输入的新密码不一致'));
} else {
callback();
}
};
const passwordRules = {
// ... oldPassword 和 newPassword 的必填和长度规则
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
};
```
## 7. API 调用
```typescript
// api/me.ts
export const meApi = {
getProfile: () => apiClient.get<UserProfile>('/me'),
updateProfile: (data: Partial<UserProfile>) => apiClient.put('/me', data),
changePassword: (data: PasswordForm) => apiClient.post('/me/change-password', data),
};
// stores/auth.ts
export const useAuthStore = defineStore('auth', {
// ... state, getters
actions: {
async fetchUserProfile() {
// (在App启动或登录后调用)
const { data } = await meApi.getProfile();
this.user = data;
},
async updateProfile(data) {
const { data: updatedUser } = await meApi.updateProfile(data);
this.user = { ...this.user, ...updatedUser };
},
async changePassword(data) {
await meApi.changePassword(data);
}
}
});
```
## 8. 样式设计
```scss
.profile-page {
padding: 24px;
.page-container {
margin-top: 24px;
}
.user-card {
text-align: center;
.user-avatar {
margin-bottom: 20px;
}
.user-info h2 {
font-size: 20px;
margin-bottom: 8px;
}
.user-info p {
color: #909399;
}
}
}
```
## 9. 测试用例
1. **集成测试**:
- 测试页面是否能正确显示当前用户的个人信息。
- 测试更新个人资料功能是否成功,并验证 store 中的用户信息是否同步更新。
- 测试修改密码的成功流程,验证是否提示成功并跳转到登录页。
- 测试修改密码的失败流程(如旧密码错误),验证是否显示正确的错误提示。
- 测试所有表单验证规则是否生效(如邮箱格式、密码长度、两次密码一致性等)。

View File

@@ -0,0 +1,315 @@
# 任务管理页面设计文档
## 1. 页面概述
任务管理页面是系统的核心功能模块之一,允许主管和管理员对任务进行全面的管理。用户可以在此页面上查看所有任务,并使用筛选、排序和搜索功能快速定位任务。此外,页面还支持任务的分配、审核以及查看任务详情等操作。
## 2. 页面布局
![任务管理页面布局示意图](https://placeholder-for-task-page-mockup.png)
### 2.1 布局结构
页面采用经典的后台管理布局:
- **顶部**: 包含页面标题和操作按钮(如"新建任务")。
- **中部**: 筛选和搜索区域,提供多种条件来过滤任务列表。
- **下部**: 任务数据表格,以列表形式展示任务,并包含操作列。
- **底部**: 分页组件,用于浏览大量任务数据。
### 2.2 响应式设计
- **桌面端**: 多列表格,所有筛选条件水平排列。
- **平板端**: 表格列数减少,部分次要信息可能隐藏,筛选条件折叠或垂直排列。
- **移动端**: 表格转为卡片列表视图,每个卡片显示一个任务的核心信息,筛选功能通过抽屉或模态框提供。
## 3. 组件结构
```vue
<template>
<div class="task-management-page">
<!-- 页面头部 -->
<el-page-header title="任务管理" content="任务列表与分配" />
<el-card class="page-container">
<!-- 筛选与搜索区域 -->
<div class="filter-container">
<el-form :model="filters" inline>
<el-form-item label="任务状态">
<el-select v-model="filters.status" placeholder="请选择状态" clearable>
<el-option label="待处理" value="PENDING" />
<el-option label="进行中" value="IN_PROGRESS" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="已审核" value="REVIEWED" />
</el-select>
</el-form-item>
<el-form-item label="执行人">
<el-input v-model="filters.assignee" placeholder="请输入执行人" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
<el-button icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 操作按钮区域 -->
<div class="action-buttons">
<el-button type="primary" icon="Plus" @click="handleCreateTask" v-permission="'SUPERVISOR'">
新建任务
</el-button>
</div>
<!-- 任务表格 -->
<el-table :data="tasks" v-loading="loading" stripe>
<el-table-column type="index" width="50" />
<el-table-column prop="title" label="任务标题" min-width="150" />
<el-table-column prop="grid" label="所属网格" />
<el-table-column prop="assignee" label="执行人" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">{{ formatStatus(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" sortable />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">查看</el-button>
<el-button link type="primary" @click="handleAssign(row)" v-if="canAssign(row)">分配</el-button>
<el-button link type="primary" @click="handleReview(row)" v-if="canReview(row)">审核</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<el-pagination
v-if="total > 0"
class="pagination-container"
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
<!-- 任务分配/审核/新建的对话框 -->
<TaskFormDialog v-model="dialogVisible" :task-id="selectedTaskId" :mode="dialogMode" @success="handleSuccess" />
</div>
</template>
```
## 4. 数据结构
```typescript
// 任务筛选条件
interface TaskFilters {
status?: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'REVIEWED';
assignee?: string;
}
// 分页信息
interface Pagination {
page: number;
pageSize: number;
}
// 任务列表项
interface TaskItem {
id: number;
title: string;
grid: string;
assignee: string | null;
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'REVIEWED';
createdAt: string;
}
// 对话框模式
type DialogMode = 'create' | 'assign' | 'review' | 'view';
```
## 5. 状态管理
```typescript
// 组件内状态
const filters = ref<TaskFilters>({});
const pagination = ref<Pagination>({ page: 1, pageSize: 10 });
const dialogVisible = ref(false);
const selectedTaskId = ref<number | null>(null);
const dialogMode = ref<DialogMode>('view');
// 全局状态 (Pinia Store)
const taskStore = useTaskStore();
const { tasks, total, loading } = storeToRefs(taskStore);
const authStore = useAuthStore();
const { user } = storeToRefs(authStore);
```
## 6. 交互逻辑
### 6.1 数据获取与筛选
```typescript
const fetchTasks = () => {
taskStore.fetchTasks({ ...filters.value, ...pagination.value });
};
onMounted(fetchTasks);
const handleSearch = () => {
pagination.value.page = 1;
fetchTasks();
};
const handleReset = () => {
filters.value = {};
handleSearch();
};
const handleSizeChange = (size: number) => {
pagination.value.pageSize = size;
fetchTasks();
};
const handleCurrentChange = (page: number) => {
pagination.value.page = page;
fetchTasks();
};
```
### 6.2 表格操作
```typescript
const handleView = (task: TaskItem) => {
selectedTaskId.value = task.id;
dialogMode.value = 'view';
dialogVisible.value = true;
};
const handleAssign = (task: TaskItem) => {
selectedTaskId.value = task.id;
dialogMode.value = 'assign';
dialogVisible.value = true;
};
const handleReview = (task: TaskItem) => {
selectedTaskId.value = task.id;
dialogMode.value = 'review';
dialogVisible.value = true;
};
const handleCreateTask = () => {
selectedTaskId.value = null;
dialogMode.value = 'create';
dialogVisible.value = true;
};
const handleSuccess = () => {
dialogVisible.value = false;
fetchTasks(); // 操作成功后刷新列表
};
```
### 6.3 权限控制
```typescript
const canAssign = (task: TaskItem) => {
return user.value?.role === 'SUPERVISOR' && task.status === 'PENDING';
};
const canReview = (task: TaskItem) => {
return user.value?.role === 'SUPERVISOR' && task.status === 'COMPLETED';
};
```
### 6.4 辅助函数
```typescript
const formatStatus = (status: TaskItem['status']) => {
const map = { PENDING: '待处理', IN_PROGRESS: '进行中', COMPLETED: '已完成', REVIEWED: '已审核' };
return map[status] || '未知';
};
// getStatusTagType 函数同仪表盘页面设计
```
## 7. API 调用
```typescript
// api/tasks.ts
export const taskApi = {
getTasks: (params) => apiClient.get('/management/tasks', { params }),
// ... 其他任务相关API
};
// stores/task.ts
export const useTaskStore = defineStore('task', {
state: () => ({
tasks: [] as TaskItem[],
total: 0,
loading: false,
}),
actions: {
async fetchTasks(params) {
this.loading = true;
try {
const { data } = await taskApi.getTasks(params);
this.tasks = data.items;
this.total = data.total;
} catch (error) {
console.error('获取任务列表失败', error);
} finally {
this.loading = false;
}
}
}
});
```
## 8. 样式设计
```scss
.task-management-page {
padding: 24px;
.page-container {
margin-top: 24px;
padding: 24px;
border-radius: 8px;
border: none;
}
.filter-container {
margin-bottom: 20px;
}
.action-buttons {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
}
```
## 9. 测试用例
1. **单元测试**:
- 测试权限控制函数 `canAssign``canReview` 的逻辑。
- 测试 `formatStatus` 辅助函数的正确性。
- 测试 Pinia store 的 action 是否能正确调用 API 并更新 state。
2. **集成测试**:
- 测试筛选、搜索和重置功能是否正常工作。
- 测试分页组件是否能正确切换页面和每页数量。
- 测试"查看"、"分配"、"审核"、"新建"按钮能否正确打开对应模式的对话框。
- 测试对话框操作成功后,任务列表是否刷新。
## 10. 性能优化
1. **后端分页**: 必须使用后端分页来处理大量任务数据。
2. **防抖**: 对搜索输入框使用防抖处理,避免用户输入时频繁触发 API 请求。
3. **组件懒加载**: `TaskFormDialog` 组件可以通过动态导入实现懒加载,只在需要时才加载。