整理
This commit is contained in:
29
ems-frontend/src/App.vue
Normal file
29
ems-frontend/src/App.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition
|
||||
enter-active-class="animate__animated animate__fadeIn"
|
||||
leave-active-class="animate__animated animate__fadeOut"
|
||||
mode="out-in"
|
||||
>
|
||||
<component :is="Component" :key="route.path" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden; /* Globally disable scrolling */
|
||||
}
|
||||
|
||||
/* You might want to adjust the animation speed */
|
||||
:root {
|
||||
--animate-duration: 0.35s;
|
||||
}
|
||||
</style>
|
||||
99
ems-frontend/src/api/auth.ts
Normal file
99
ems-frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// @/api/auth.ts
|
||||
import apiClient from './index';
|
||||
import type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
SignUpRequest,
|
||||
ResetPasswordWithCodeRequest
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 用户登录(使用 Fetch API)
|
||||
* @param credentials - 登录凭据 (邮箱和密码)
|
||||
* @returns Promise<LoginResponse>
|
||||
*/
|
||||
export const loginWithFetch = async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP error ${response.status}: ${response.statusText}`;
|
||||
const responseText = await response.text();
|
||||
try {
|
||||
const errorBody = JSON.parse(responseText);
|
||||
if (errorBody && errorBody.message) {
|
||||
errorMessage = errorBody.message;
|
||||
} else if (responseText) {
|
||||
errorMessage = responseText;
|
||||
}
|
||||
} catch (e) {
|
||||
if (responseText) {
|
||||
errorMessage = responseText;
|
||||
}
|
||||
}
|
||||
const error: any = new Error(errorMessage);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// If response is OK, we need to parse it as JSON.
|
||||
// The stream hasn't been read if response.ok is true.
|
||||
const data = await response.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param credentials - 登录凭据 (邮箱和密码)
|
||||
* @returns Promise<AxiosResponse<LoginResponse>>
|
||||
*/
|
||||
export const login = (credentials: LoginRequest) => {
|
||||
return apiClient.post<LoginResponse>('/auth/login', credentials);
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* @param data - 注册所需信息
|
||||
*/
|
||||
export const signup = (data: SignUpRequest) => {
|
||||
return apiClient.post('/auth/signup', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送【注册】验证码
|
||||
* @param email - 目标邮箱
|
||||
*/
|
||||
export const sendVerificationCode = (email: string) => {
|
||||
return apiClient.post(`/auth/send-verification-code?email=${email}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送【密码重置】验证码
|
||||
* @param email - 目标邮箱
|
||||
*/
|
||||
export const sendPasswordResetCode = (email: string) => {
|
||||
return apiClient.post(`/auth/send-password-reset-code?email=${email}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用验证码重置密码
|
||||
* @param data - 包含邮箱、验证码和新密码的对象
|
||||
*/
|
||||
export const resetPasswordWithCode = (data: ResetPasswordWithCodeRequest) => {
|
||||
return apiClient.post('/auth/reset-password-with-code', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
* 通知后端记录登出操作
|
||||
*/
|
||||
export const logout = () => {
|
||||
return apiClient.post('/auth/logout');
|
||||
};
|
||||
|
||||
// ... 其他认证相关的API调用
|
||||
147
ems-frontend/src/api/dashboard.ts
Normal file
147
ems-frontend/src/api/dashboard.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import apiClient from './index';
|
||||
import type {
|
||||
DashboardStatsDTO,
|
||||
AqiDistributionDTO,
|
||||
TaskStatsDTO,
|
||||
TrendDataPointDTO,
|
||||
GridCoverageDTO,
|
||||
HeatmapPointDTO,
|
||||
PollutionStatsDTO,
|
||||
AqiHeatmapPointDTO,
|
||||
PollutantThresholdDTO,
|
||||
Grid
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Fetches the main dashboard statistics.
|
||||
* @returns A promise that resolves to the dashboard stats.
|
||||
*/
|
||||
export const getDashboardStats = (): Promise<DashboardStatsDTO> => {
|
||||
return apiClient.get('/dashboard/stats');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the AQI level distribution.
|
||||
* @returns A promise that resolves to an array of AQI distribution data.
|
||||
*/
|
||||
export const getAqiDistribution = (): Promise<AqiDistributionDTO[]> => {
|
||||
return apiClient.get('/dashboard/reports/aqi-distribution');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the monthly trend of exceedance events.
|
||||
* @returns A promise that resolves to an array of trend data points.
|
||||
*/
|
||||
export const getMonthlyExceedanceTrend = (): Promise<TrendDataPointDTO[]> => {
|
||||
return apiClient.get('/dashboard/reports/monthly-exceedance-trend')
|
||||
.then(data => {
|
||||
console.log('原始趋势数据:', data);
|
||||
// 确保数据格式正确
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => ({
|
||||
yearMonth: item.yearMonth || '',
|
||||
count: item.count || 0
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the grid coverage statistics by city.
|
||||
* @returns A promise that resolves to an array of grid coverage data.
|
||||
*/
|
||||
export const getGridCoverageByCity = (): Promise<GridCoverageDTO[]> => {
|
||||
return apiClient.get('/dashboard/reports/grid-coverage');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the heatmap data for pollution incidents.
|
||||
* @returns A promise that resolves to an array of heatmap points.
|
||||
*/
|
||||
export const getHeatmapData = (): Promise<HeatmapPointDTO[]> => {
|
||||
return apiClient.get('/dashboard/map/heatmap');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the pollution statistics.
|
||||
* @returns A promise that resolves to an array of pollution stats.
|
||||
*/
|
||||
export const getPollutionStats = (): Promise<PollutionStatsDTO[]> => {
|
||||
return apiClient.get('/dashboard/reports/pollution-stats');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the task completion statistics.
|
||||
* @returns A promise that resolves to the task stats.
|
||||
*/
|
||||
export const getTaskCompletionStats = (): Promise<TaskStatsDTO> => {
|
||||
return apiClient.get('/dashboard/reports/task-completion-stats');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the AQI heatmap data.
|
||||
* @returns A promise that resolves to an array of AQI heatmap points.
|
||||
*/
|
||||
export const getAqiHeatmapData = (): Promise<AqiHeatmapPointDTO[]> => {
|
||||
return apiClient.get('/dashboard/map/aqi-heatmap');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all pollutant thresholds.
|
||||
* @returns A promise that resolves to an array of pollutant thresholds.
|
||||
*/
|
||||
export const getPollutantThresholds = (): Promise<PollutantThresholdDTO[]> => {
|
||||
return apiClient.get('/dashboard/thresholds');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a specific pollutant threshold.
|
||||
* @param pollutantName The name of the pollutant.
|
||||
* @returns A promise that resolves to the pollutant threshold.
|
||||
*/
|
||||
export const getPollutantThreshold = (pollutantName: string): Promise<PollutantThresholdDTO> => {
|
||||
return apiClient.get(`/dashboard/thresholds/${pollutantName}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves a pollutant threshold.
|
||||
* @param threshold The pollutant threshold to save.
|
||||
* @returns A promise that resolves to the saved pollutant threshold.
|
||||
*/
|
||||
export const savePollutantThreshold = (threshold: PollutantThresholdDTO): Promise<PollutantThresholdDTO> => {
|
||||
return apiClient.post('/dashboard/thresholds', threshold);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取每种污染物的月度趋势数据
|
||||
* @returns 每种污染物的月度趋势数据
|
||||
*/
|
||||
export const getPollutantMonthlyTrends = (value: string): Promise<Record<string, TrendDataPointDTO[]>> => {
|
||||
return apiClient.get('/dashboard/reports/pollutant-monthly-trends')
|
||||
.then(data => {
|
||||
console.log('原始污染物趋势数据:', data);
|
||||
// 确保数据格式正确
|
||||
if (data && typeof data === 'object') {
|
||||
const result: Record<string, TrendDataPointDTO[]> = {};
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
result[key] = value.map(item => ({
|
||||
yearMonth: item.yearMonth || '',
|
||||
count: item.count || 0
|
||||
}));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all grid data from the server.
|
||||
* @returns A promise that resolves to an array of Grid objects.
|
||||
*/
|
||||
export const getGrids = (): Promise<Grid[]> => {
|
||||
return apiClient.get('/grids');
|
||||
};
|
||||
299
ems-frontend/src/api/feedback.ts
Normal file
299
ems-frontend/src/api/feedback.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import apiClient from './index';
|
||||
import type { Page, AssigneeInfoDTO, UserInfoDTO, TaskInfoDTO, AttachmentDTO } from './types';
|
||||
import { PollutionType } from './types';
|
||||
|
||||
// 导出必要的类型
|
||||
export { PollutionType };
|
||||
|
||||
// API URL
|
||||
const API_URL = '/feedback';
|
||||
|
||||
// --- Start: Copied from TaskDetailView.vue for now ---
|
||||
// This is technical debt and should be refactored into a central types file.
|
||||
interface FeedbackDTO {
|
||||
feedbackId: number;
|
||||
eventId: string;
|
||||
title: string;
|
||||
}
|
||||
interface AssigneeDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
interface TaskHistoryDTO {
|
||||
id: number;
|
||||
oldStatus: string | null;
|
||||
newStatus: string;
|
||||
comments: string;
|
||||
changedAt: string;
|
||||
changedBy: {
|
||||
id: number;
|
||||
name:string;
|
||||
};
|
||||
}
|
||||
interface SubmissionInfoDTO {
|
||||
comments: string;
|
||||
attachments: AttachmentDTO[];
|
||||
}
|
||||
export interface TaskDetail {
|
||||
id: number;
|
||||
feedback: FeedbackDTO;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
assignee?: AssigneeDTO;
|
||||
assignedAt: string | null;
|
||||
completedAt: string | null;
|
||||
history: TaskHistoryDTO[];
|
||||
submissionInfo: SubmissionInfoDTO | null;
|
||||
gridX?: number | null;
|
||||
gridY?: number | null;
|
||||
assignment?: {
|
||||
assignmentTime?: string;
|
||||
remarks?: string;
|
||||
};
|
||||
}
|
||||
// --- End: Copied from TaskDetailView.vue ---
|
||||
|
||||
/**
|
||||
* 反馈状态枚举
|
||||
*/
|
||||
export enum FeedbackStatus {
|
||||
AI_REVIEWING = 'AI_REVIEWING', // AI审核中
|
||||
PENDING_REVIEW = 'PENDING_REVIEW', // 待人工审核
|
||||
REJECTED = 'REJECTED', // 已拒绝
|
||||
CONFIRMED = 'CONFIRMED', // 已确认
|
||||
ASSIGNED = 'ASSIGNED', // 已分配
|
||||
IN_PROGRESS = 'IN_PROGRESS', // 处理中
|
||||
RESOLVED = 'RESOLVED', // 已解决
|
||||
CLOSED = 'CLOSED', // 已关闭
|
||||
PENDING_ASSIGNMENT = 'PENDING_ASSIGNMENT', // 待分配
|
||||
PROCESSED = 'PROCESSED' // 已处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 严重程度枚举
|
||||
*/
|
||||
export enum SeverityLevel {
|
||||
LOW = 'LOW', // 低
|
||||
MEDIUM = 'MEDIUM', // 中
|
||||
HIGH = 'HIGH' // 高
|
||||
}
|
||||
|
||||
/**
|
||||
* 反馈列表项接口 (现在匹配后端的 FeedbackResponseDTO)
|
||||
*/
|
||||
export interface FeedbackResponse {
|
||||
id: number;
|
||||
eventId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pollutionType: PollutionType;
|
||||
severityLevel: SeverityLevel;
|
||||
status: FeedbackStatus;
|
||||
textAddress: string;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
createdAt: string; // ISO date string
|
||||
updatedAt: string; // ISO date string
|
||||
submitterId: number;
|
||||
user: UserInfoDTO;
|
||||
task: TaskDetail | null; // Task can be null if not assigned
|
||||
attachments: AttachmentDTO[];
|
||||
reporterPhone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反馈详情接口 (为了简化,我们可以让它继承自 FeedbackResponse)
|
||||
*/
|
||||
export interface FeedbackDetail extends FeedbackResponse {
|
||||
aiAnalysis?: string;
|
||||
reviewNotes?: string;
|
||||
assignmentId?: number;
|
||||
assignmentMethod?: string;
|
||||
assignmentTime?: string;
|
||||
assignedWorkerId?: number;
|
||||
resolutionNotes?: string;
|
||||
resolutionTime?: string;
|
||||
// `images` is now `attachments`, which is already in FeedbackResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 反馈筛选参数接口
|
||||
*/
|
||||
export interface FeedbackFilters {
|
||||
status?: FeedbackStatus;
|
||||
pollutionType?: PollutionType;
|
||||
severityLevel?: SeverityLevel;
|
||||
cityName?: string;
|
||||
districtName?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
keyword?: string;
|
||||
submitterId?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反馈处理请求接口
|
||||
*/
|
||||
export interface ProcessFeedbackRequest {
|
||||
status: FeedbackStatus;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反馈分配请求接口
|
||||
*/
|
||||
export interface AssignFeedbackRequest {
|
||||
gridWorkerId: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反馈拒绝请求接口
|
||||
*/
|
||||
export interface RejectFeedbackRequest {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface LocationInfo {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
textAddress: string;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
}
|
||||
|
||||
export interface FeedbackSubmissionRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
pollutionType: PollutionType;
|
||||
severityLevel: SeverityLevel;
|
||||
location: LocationInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反馈列表
|
||||
* @param params 筛选参数
|
||||
* @returns 分页反馈列表
|
||||
*/
|
||||
export function getFeedbackList(params?: FeedbackFilters): Promise<Page<FeedbackResponse>> {
|
||||
return apiClient.get(API_URL, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反馈详情
|
||||
* @param id 反馈ID
|
||||
* @returns 反馈详情
|
||||
*/
|
||||
export function getFeedbackDetail(id: number): Promise<FeedbackDetail> {
|
||||
return apiClient.get(`/feedback/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理反馈
|
||||
* @param id 反馈ID
|
||||
* @param data 处理数据
|
||||
* @returns 处理结果
|
||||
*/
|
||||
export function processFeedback(id: number, request: { status: string; notes?: string }): Promise<FeedbackDetail> {
|
||||
return apiClient.post(`/feedback/${id}/process`, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配反馈给网格员
|
||||
* @param id 反馈ID
|
||||
* @param data 分配数据
|
||||
* @returns 分配结果
|
||||
*/
|
||||
export function assignFeedback(id: number, data: AssignFeedbackRequest): Promise<any> {
|
||||
return apiClient.post(`${API_URL}/${id}/assign`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记反馈为已解决
|
||||
* @param id 反馈ID
|
||||
* @param notes 解决说明
|
||||
* @returns 操作结果
|
||||
*/
|
||||
export function resolveFeedback(id: number, notes?: string): Promise<any> {
|
||||
return apiClient.post(`${API_URL}/${id}/resolve`, { notes });
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭反馈
|
||||
* @param id 反馈ID
|
||||
* @param notes 关闭说明
|
||||
* @returns 操作结果
|
||||
*/
|
||||
export function closeFeedback(id: number, notes?: string): Promise<any> {
|
||||
return apiClient.post(`${API_URL}/${id}/close`, { notes });
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝反馈
|
||||
* @param id 反馈ID
|
||||
* @param data 拒绝数据
|
||||
* @returns 操作结果
|
||||
*/
|
||||
export function rejectFeedback(id: number, data: RejectFeedbackRequest): Promise<any> {
|
||||
return apiClient.post(`/supervisor/reviews/${id}/reject`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认反馈
|
||||
* @param id 反馈ID
|
||||
* @returns 响应体
|
||||
*/
|
||||
export function confirmFeedback(id: number): Promise<any> {
|
||||
return apiClient.post(`${API_URL}/${id}/confirm`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交新的反馈
|
||||
* @param formData 包含反馈数据和文件的表单
|
||||
* @returns 提交结果
|
||||
*/
|
||||
export function submitFeedback(formData: FormData): Promise<any> {
|
||||
return apiClient.post(`${API_URL}/submit`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反馈统计数据
|
||||
* @returns 反馈统计数据
|
||||
*/
|
||||
export const getFeedbackStats = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(`${API_URL}/stats`);
|
||||
return response || {}; // 如果响应为空,返回一个空对象
|
||||
} catch (error) {
|
||||
console.error('获取反馈统计数据API错误:', error);
|
||||
// 即使API失败也返回一个默认的统计对象结构,防止UI崩溃
|
||||
return {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
confirmed: 0,
|
||||
assigned: 0,
|
||||
inProgress: 0,
|
||||
resolved: 0,
|
||||
closed: 0,
|
||||
rejected: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取待处理的反馈列表
|
||||
* @returns 待处理的反馈列表
|
||||
*/
|
||||
export function getPendingFeedback(): Promise<FeedbackResponse[]> {
|
||||
return apiClient.get('/feedback/pending');
|
||||
}
|
||||
95
ems-frontend/src/api/grid.ts
Normal file
95
ems-frontend/src/api/grid.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import apiClient from './index';
|
||||
import type { Grid, UserAccount } from './types';
|
||||
|
||||
export interface GridData extends Grid {}
|
||||
|
||||
export type GridWorker = UserAccount;
|
||||
|
||||
export interface GridWorkersQuery {
|
||||
unassigned?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a grid's core properties.
|
||||
* This should align with GridUpdateRequest in the backend.
|
||||
*/
|
||||
export interface GridUpdateRequest {
|
||||
isObstacle?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新网格信息
|
||||
* @param gridId - 要更新的网格ID
|
||||
* @param data - 包含更新数据的对象
|
||||
* @returns 更新后的网格对象
|
||||
*/
|
||||
export function updateGrid(gridId: number, data: GridUpdateRequest): Promise<Grid>;
|
||||
export function updateGrid(grid: GridData & GridUpdateRequest): Promise<Grid>;
|
||||
export function updateGrid(
|
||||
gridOrId: number | (GridData & GridUpdateRequest),
|
||||
data?: GridUpdateRequest
|
||||
): Promise<Grid> {
|
||||
if (typeof gridOrId === 'number') {
|
||||
return apiClient.patch(`/grids/${gridOrId}`, data ?? {});
|
||||
}
|
||||
const payload: GridUpdateRequest = {
|
||||
isObstacle: gridOrId.isObstacle,
|
||||
description: gridOrId.description,
|
||||
};
|
||||
return apiClient.patch(`/grids/${gridOrId.id}`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有网格
|
||||
*/
|
||||
export const getGrids = (): Promise<GridData[]> => {
|
||||
return apiClient.get('/grids');
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取网格员列表(可选:仅未分配)
|
||||
*/
|
||||
export const getGridWorkers = async (params?: GridWorkersQuery): Promise<GridWorker[]> => {
|
||||
const response = await apiClient.get('/personnel/users', {
|
||||
params: { role: 'GRID_WORKER' },
|
||||
});
|
||||
let workers: GridWorker[] = Array.isArray(response) ? response : (response as any).content || [];
|
||||
|
||||
if (params?.unassigned) {
|
||||
workers = workers.filter(worker =>
|
||||
worker.gridX === null ||
|
||||
worker.gridY === null ||
|
||||
worker.gridX === undefined ||
|
||||
worker.gridY === undefined ||
|
||||
worker.gridX === -1 ||
|
||||
worker.gridY === -1
|
||||
);
|
||||
}
|
||||
return workers;
|
||||
};
|
||||
|
||||
/**
|
||||
* 通过坐标将网格员分配到网格
|
||||
* @param gridX - 网格X坐标
|
||||
* @param gridY - 网格Y坐标
|
||||
* @param userId - 要分配的用户ID
|
||||
* @returns 成功则返回void
|
||||
*/
|
||||
export const assignWorkerByCoordinates = (gridX: number, gridY: number, userId: number): Promise<void> => {
|
||||
return apiClient.post(`/grids/coordinates/${gridX}/${gridY}/assign`, { userId });
|
||||
};
|
||||
|
||||
/**
|
||||
* 通过坐标从网格中移除网格员
|
||||
* @param gridX - 网格X坐标
|
||||
* @param gridY - 网格Y坐标
|
||||
* @returns 成功则返回void
|
||||
*/
|
||||
export const unassignWorkerByCoordinates = (gridX: number, gridY: number): Promise<void> => {
|
||||
return apiClient.post(`/grids/coordinates/${gridX}/${gridY}/unassign`);
|
||||
};
|
||||
|
||||
// Aliases for compatibility with existing usage
|
||||
export const assignGridWorkerByCoordinates = assignWorkerByCoordinates;
|
||||
export const removeGridWorkerByCoordinates = unassignWorkerByCoordinates;
|
||||
61
ems-frontend/src/api/index.ts
Normal file
61
ems-frontend/src/api/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
interface ApiClient extends AxiosInstance {
|
||||
request<T = any, R = T>(config: AxiosRequestConfig): Promise<R>;
|
||||
get<T = any, R = T>(url: string, config?: AxiosRequestConfig): Promise<R>;
|
||||
delete<T = any, R = T>(url: string, config?: AxiosRequestConfig): Promise<R>;
|
||||
head<T = any, R = T>(url: string, config?: AxiosRequestConfig): Promise<R>;
|
||||
options<T = any, R = T>(url: string, config?: AxiosRequestConfig): Promise<R>;
|
||||
post<T = any, R = T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<R>;
|
||||
put<T = any, R = T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<R>;
|
||||
patch<T = any, R = T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<R>;
|
||||
}
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // 从环境变量或默认值设置基础URL
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}) as ApiClient;
|
||||
|
||||
// 添加请求拦截器
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// 这个拦截器会在每个请求发送前执行
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
// 为请求头添加Authorization字段
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
// 对请求错误做些什么
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// 对响应数据做点什么
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
// 对响应错误做点什么
|
||||
if (error.response && error.response.status === 401) {
|
||||
// 如果是401错误,说明token无效或过期
|
||||
// 我们需要调用auth store的logout方法
|
||||
// 为了避免循环依赖,我们在函数内部获取store实例
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
// 在这里重定向到登录页
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
10
ems-frontend/src/api/log.ts
Normal file
10
ems-frontend/src/api/log.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import apiClient from '.';
|
||||
import type { OperationLog } from './types';
|
||||
|
||||
export function getOperationLogs(params: any): Promise<OperationLog[]> {
|
||||
return apiClient.get('/logs', { params });
|
||||
}
|
||||
|
||||
export function getMyOperationLogs(): Promise<OperationLog[]> {
|
||||
return apiClient.get('/logs/my-logs');
|
||||
}
|
||||
30
ems-frontend/src/api/pathfinding.ts
Normal file
30
ems-frontend/src/api/pathfinding.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import apiClient from './index';
|
||||
|
||||
/**
|
||||
* Represents a point with x and y coordinates.
|
||||
* Matches the backend Point DTO.
|
||||
*/
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the request payload for finding a path.
|
||||
* Matches the backend PathfindingRequest DTO.
|
||||
*/
|
||||
export interface PathfindingRequest {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the backend API to find a path between two points.
|
||||
* @param request The pathfinding request containing start and end coordinates.
|
||||
* @returns A promise that resolves to a list of points representing the path.
|
||||
*/
|
||||
export const findPath = (request: PathfindingRequest): Promise<Point[]> => {
|
||||
return apiClient.post('/pathfinding/find', request);
|
||||
};
|
||||
40
ems-frontend/src/api/personnel.ts
Normal file
40
ems-frontend/src/api/personnel.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import apiClient from './index';
|
||||
import type { Page, Pageable, UserAccount, UserUpdateRequest } from './types';
|
||||
|
||||
/**
|
||||
* 获取用户列表(可分页、可筛选)
|
||||
* @param params - 包含分页和筛选条件的参数
|
||||
* @returns 用户数据分页对象
|
||||
*/
|
||||
export const getUsers = (params: { role?: string; name?: string; } & Pageable): Promise<Page<UserAccount>> => {
|
||||
return apiClient.get('/personnel/users', { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有网格员信息
|
||||
* @returns 所有网格员的列表
|
||||
*/
|
||||
export const getAllGridWorkers = async (): Promise<UserAccount[]> => {
|
||||
// We fetch with a large size to get all workers, assuming the number is manageable.
|
||||
// A more robust solution might involve paginating through all results if the number of workers is very large.
|
||||
const response = await getUsers({ role: 'GRID_WORKER', page: 0, size: 1000 });
|
||||
return response.content;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID更新用户信息
|
||||
* @param userId - 用户ID
|
||||
* @param data - 需要更新的用户信息
|
||||
* @returns 更新后的用户信息
|
||||
*/
|
||||
export const updateUser = (userId: number, data: UserUpdateRequest): Promise<UserAccount> => {
|
||||
return apiClient.patch(`/personnel/users/${userId}`, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID删除用户
|
||||
* @param userId - 用户ID
|
||||
*/
|
||||
export const deleteUser = (userId: number): Promise<void> => {
|
||||
return apiClient.delete(`/personnel/users/${userId}`);
|
||||
};
|
||||
15
ems-frontend/src/api/public.ts
Normal file
15
ems-frontend/src/api/public.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import apiClient from './index';
|
||||
|
||||
/**
|
||||
* Submits public feedback.
|
||||
* This endpoint is for unauthenticated users (guests).
|
||||
* @param formData The form data containing the feedback details and any files.
|
||||
* @returns A promise that resolves on successful submission.
|
||||
*/
|
||||
export const submitPublicFeedback = (formData: FormData): Promise<any> => {
|
||||
return apiClient.post('/public/feedback', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
};
|
||||
35
ems-frontend/src/api/tasks.ts
Normal file
35
ems-frontend/src/api/tasks.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import apiClient from './index';
|
||||
import type { UserAccount } from './types';
|
||||
|
||||
export const getAvailableGridWorkers = (): Promise<UserAccount[]> => {
|
||||
// 根据TaskAssignmentController的路径应为/tasks/grid-workers
|
||||
return apiClient.get<UserAccount[]>('/tasks/grid-workers')
|
||||
.catch((error: any) => {
|
||||
console.error('获取可用网格员API错误:', error);
|
||||
// 如果API调用失败,返回空数组而不是模拟数据
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
export const assignTask = (feedbackId: number, assigneeId: number): Promise<any> => {
|
||||
return apiClient.post('/tasks/assign', { feedbackId, assigneeId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Approves a submitted task.
|
||||
* @param taskId The ID of the task to approve.
|
||||
* @returns A promise that resolves with the updated task details.
|
||||
*/
|
||||
export const approveTask = (taskId: number): Promise<any> => {
|
||||
return apiClient.post(`/management/tasks/${taskId}/approve`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Rejects a submitted task.
|
||||
* @param taskId The ID of the task to reject.
|
||||
* @param reason The reason for rejection.
|
||||
* @returns A promise that resolves with the updated task details.
|
||||
*/
|
||||
export const rejectTask = (taskId: number, reason: string): Promise<any> => {
|
||||
return apiClient.post(`/management/tasks/${taskId}/reject`, { reason });
|
||||
};
|
||||
308
ems-frontend/src/api/types.ts
Normal file
308
ems-frontend/src/api/types.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 通用用户信息类型, 反映了JWT中包含的声明
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
phone?: string;
|
||||
status?: string;
|
||||
region?: string;
|
||||
skills?: string[];
|
||||
gridX?: number;
|
||||
gridY?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录请求体
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功响应体, 与后端完全匹配
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册请求体
|
||||
*/
|
||||
export interface SignUpRequest {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
password: string;
|
||||
verificationCode: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用验证码重置密码请求体, 对应后端的 PasswordResetWithCodeDto
|
||||
*/
|
||||
export interface ResetPasswordWithCodeRequest {
|
||||
email: string;
|
||||
code: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页请求参数
|
||||
*/
|
||||
export interface Pageable {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应体
|
||||
*/
|
||||
export interface Page<T> {
|
||||
content: T[];
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
size: number;
|
||||
number: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 UserAccount 实体类的完整映射
|
||||
*/
|
||||
export interface UserAccount {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
status: string;
|
||||
gender?: string;
|
||||
region?: string;
|
||||
gridX?: number;
|
||||
gridY?: number;
|
||||
level?: string;
|
||||
skills?: string[];
|
||||
currentLatitude?: number;
|
||||
currentLongitude?: number;
|
||||
createdAt: string; // Assuming ISO date string
|
||||
updatedAt: string; // Assuming ISO date string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户更新请求体, 对应后端的 UserUpdateRequest
|
||||
*/
|
||||
export interface UserUpdateRequest {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
region?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
gender?: 'MALE' | 'FEMALE' | 'OTHER';
|
||||
gridX?: number;
|
||||
gridY?: number;
|
||||
level?: string;
|
||||
skills?: string[];
|
||||
currentLatitude?: number;
|
||||
currentLongitude?: number;
|
||||
}
|
||||
|
||||
// --- Dashboard DTOs ---
|
||||
|
||||
/**
|
||||
* Key statistics for the main dashboard.
|
||||
* @param totalFeedbacks - Total number of feedbacks.
|
||||
* @param confirmedFeedbacks - Number of confirmed feedbacks.
|
||||
* @param totalAqiRecords - Total number of AQI records.
|
||||
* @param activeGridWorkers - Number of active grid workers.
|
||||
*/
|
||||
export type DashboardStatsDTO = {
|
||||
totalFeedbacks: number;
|
||||
confirmedFeedbacks: number;
|
||||
totalAqiRecords: number;
|
||||
activeGridWorkers: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Data for AQI level distribution chart.
|
||||
* @param level - The AQI descriptive level (e.g., "Good", "Moderate").
|
||||
* @param count - The number of monitoring points at this level.
|
||||
*/
|
||||
export type AqiDistributionDTO = {
|
||||
level: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single data point for a time-series trend chart.
|
||||
* @param yearMonth - The date for the data point in "YYYY-MM" format.
|
||||
* @param count - The value for that date (e.g., number of exceedances).
|
||||
*/
|
||||
export type TrendDataPointDTO = {
|
||||
yearMonth: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Grid coverage statistics for a specific city.
|
||||
* @param city - The name of the city.
|
||||
* @param totalGrids - The total number of grids in the city.
|
||||
* @param coveredGrids - The number of grids with assigned personnel.
|
||||
* @param coverageRate - The calculated coverage percentage.
|
||||
*/
|
||||
export type GridCoverageDTO = {
|
||||
city: string;
|
||||
totalGrids: number;
|
||||
coveredGrids: number;
|
||||
coverageRate: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single point for a heatmap visualization.
|
||||
* @param lat - Latitude of the data point.
|
||||
* @param lng - Longitude of the data point.
|
||||
* @param value - The intensity value for the heatmap.
|
||||
*/
|
||||
export type HeatmapPointDTO = {
|
||||
lat: number;
|
||||
lng: number;
|
||||
value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detailed statistics for a specific pollutant.
|
||||
* @param pollutantName - The name of the pollutant (e.g., "PM2.5").
|
||||
* @param averageValue - The average value over a period.
|
||||
* @param maxValue - The maximum recorded value.
|
||||
* @param unit - The measurement unit (e.g., "µg/m³").
|
||||
*/
|
||||
export type PollutionStatsDTO = {
|
||||
pollutantName: string;
|
||||
averageValue: number;
|
||||
maxValue: number;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Statistics on task completion status.
|
||||
* @param totalTasks - Total number of tasks.
|
||||
* @param completedTasks - Number of completed tasks.
|
||||
* @param completionRate - Completion rate of tasks.
|
||||
*/
|
||||
export type TaskStatsDTO = {
|
||||
totalTasks: number;
|
||||
completedTasks: number;
|
||||
completionRate: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single point for the AQI heatmap, including pollutant info.
|
||||
* Extends HeatmapPointDTO with additional details.
|
||||
*/
|
||||
export type AqiHeatmapPointDTO = HeatmapPointDTO & {
|
||||
primaryPollutant: string;
|
||||
aqi: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 污染物类型枚举
|
||||
*/
|
||||
export enum PollutionType {
|
||||
PM25 = 'PM25',
|
||||
O3 = 'O3',
|
||||
NO2 = 'NO2',
|
||||
SO2 = 'SO2',
|
||||
OTHER = 'OTHER'
|
||||
}
|
||||
|
||||
/**
|
||||
* 污染物阈值设置
|
||||
* @param pollutionType - 污染物类型
|
||||
* @param pollutantName - 污染物名称
|
||||
* @param threshold - 阈值
|
||||
* @param unit - 单位
|
||||
* @param description - 描述
|
||||
*/
|
||||
export type PollutantThresholdDTO = {
|
||||
pollutionType: PollutionType;
|
||||
pollutantName: string;
|
||||
threshold: number;
|
||||
unit: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a geographical grid cell.
|
||||
* Matches the backend Grid entity.
|
||||
*/
|
||||
export interface Grid {
|
||||
id: number;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
cityName: string;
|
||||
districtName?: string;
|
||||
description?: string;
|
||||
isObstacle: boolean;
|
||||
}
|
||||
|
||||
// ... 您可以根据API文档继续添加其他类型定义
|
||||
|
||||
// --- Task and Assignment DTOs ---
|
||||
|
||||
export interface AssigneeInfoDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface UserInfoDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface TaskInfoDTO {
|
||||
id: number;
|
||||
status: string; // Should match TaskStatus enum from backend
|
||||
assignee: AssigneeInfoDTO;
|
||||
submissionInfo: SubmissionInfoDTO | null;
|
||||
}
|
||||
|
||||
export interface AttachmentDTO {
|
||||
id: number;
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
url: string;
|
||||
uploadedAt: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface SubmissionInfoDTO {
|
||||
comments: string;
|
||||
attachments: AttachmentDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作日志类型
|
||||
*/
|
||||
export interface OperationLog {
|
||||
id: number;
|
||||
userId: number;
|
||||
userName: string;
|
||||
operationType: string;
|
||||
operationTypeDesc: string;
|
||||
description: string;
|
||||
targetId: string;
|
||||
targetType: string;
|
||||
ipAddress: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Summary DTO for supervisor view.
|
||||
* This should match `TaskSummaryDTO` from the backend.
|
||||
*/
|
||||
74
ems-frontend/src/api/user.ts
Normal file
74
ems-frontend/src/api/user.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import apiClient from './index';
|
||||
import type { GridWorker } from './grid';
|
||||
|
||||
/**
|
||||
* 用户信息接口
|
||||
*/
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
avatar?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户更新请求接口
|
||||
*/
|
||||
export interface UserUpdateRequest {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
region?: string;
|
||||
level?: string;
|
||||
gridX?: number | null;
|
||||
gridY?: number | null;
|
||||
currentLatitude?: number | null;
|
||||
currentLongitude?: number | null;
|
||||
skills?: string[];
|
||||
status?: string;
|
||||
gender?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* @returns 用户信息
|
||||
*/
|
||||
export function getCurrentUser(): Promise<UserInfo> {
|
||||
return apiClient.get('/auth/profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户资料
|
||||
* @param userId 用户ID
|
||||
* @param data 更新数据
|
||||
* @returns 更新后的用户信息
|
||||
*/
|
||||
export function updateUserProfile(userId: number, data: UserUpdateRequest): Promise<GridWorker> {
|
||||
console.log(`API调用: 更新用户资料, userId=${userId}`, data);
|
||||
return apiClient.patch(`/personnel/users/${userId}`, data)
|
||||
.then(response => {
|
||||
console.log('更新用户资料API调用成功:', response);
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('更新用户资料API调用失败:', error);
|
||||
console.error('错误详情:', error.response?.data || error.message);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户角色
|
||||
* @param userId 用户ID
|
||||
* @param role 角色名称
|
||||
* @param gridX 网格X坐标(可选)
|
||||
* @param gridY 网格Y坐标(可选)
|
||||
* @returns 更新后的用户信息
|
||||
*/
|
||||
export function updateUserRole(userId: number, role: string, gridX?: number, gridY?: number): Promise<GridWorker> {
|
||||
const data = { role, gridX, gridY };
|
||||
return apiClient.put(`/personnel/users/${userId}/role`, data);
|
||||
}
|
||||
90
ems-frontend/src/assets/base.css
Normal file
90
ems-frontend/src/assets/base.css
Normal file
@@ -0,0 +1,90 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-green-dark: #2E7D32;
|
||||
--vt-c-green: #4CAF50;
|
||||
--vt-c-green-light: #81C784;
|
||||
--vt-c-green-soft: #C8E6C9;
|
||||
--vt-c-green-mute: #E8F5E9;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-green-dark);
|
||||
--vt-c-text-light-2: rgba(46, 125, 50, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-green-mute);
|
||||
--color-background-soft: var(--vt-c-green-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-green-light);
|
||||
--color-text: var(--vt-c-green-soft);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
1
ems-frontend/src/assets/main.css
Normal file
1
ems-frontend/src/assets/main.css
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
608
ems-frontend/src/components/FeedbackDetailDialog.vue
Normal file
608
ems-frontend/src/components/FeedbackDetailDialog.vue
Normal file
@@ -0,0 +1,608 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="反馈详情"
|
||||
width="70%"
|
||||
:before-close="handleClose"
|
||||
class="feedback-detail-dialog"
|
||||
top="5vh"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<span class="el-dialog__title">反馈详情</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Refresh"
|
||||
circle
|
||||
@click="handleRefresh"
|
||||
:loading="loading"
|
||||
title="刷新数据"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="loading" v-loading="loading" class="loading-container"></div>
|
||||
<div v-if="!loading && currentFeedback" class="detail-container">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ currentFeedback.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="事件编号">{{ currentFeedback.eventId || '无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题" :span="2">{{ currentFeedback.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="污染类型">{{ formatPollutionType(currentFeedback.pollutionType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="严重程度">
|
||||
<el-tag :type="getSeverityTagType(currentFeedback.severityLevel)">
|
||||
{{ formatSeverityLevel(currentFeedback.severityLevel) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="getStatusTagType(currentFeedback.status)">
|
||||
{{ formatStatus(currentFeedback.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间">{{ formatDate(currentFeedback.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDate(currentFeedback.updatedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-descriptions :column="1" border class="mt-4">
|
||||
<el-descriptions-item label="详细描述">
|
||||
<div class="description-content">{{ currentFeedback.description }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提交位置" v-if="currentFeedback.latitude && currentFeedback.longitude">
|
||||
{{ currentFeedback.textAddress || `经纬度: (${currentFeedback.longitude}, ${currentFeedback.latitude})` }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="报告人信息" v-if="currentFeedback.user">
|
||||
{{ currentFeedback.user.name }}
|
||||
<el-tag size="small" style="margin-left: 8px;">{{ formatRole(currentFeedback.user.role) }}</el-tag>
|
||||
<div class="mt-1">ID: {{ currentFeedback.user.id }}, 电话: {{ currentFeedback.reporterPhone || '未提供' }}</div>
|
||||
<div class="user-note mt-1" v-if="currentFeedback.user.role === 'GRID_WORKER'">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="注意:通常反馈应由公众监督员提交,此处显示为网格员可能是测试数据"
|
||||
/>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="task-details mt-4" v-if="currentFeedback.task">
|
||||
<h4>任务详情</h4>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="任务ID">{{ currentFeedback.task.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务状态">
|
||||
<el-tag :type="getStatusTagType(currentFeedback.task.status)">
|
||||
{{ formatStatus(currentFeedback.task.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理人" v-if="currentFeedback.task.assignee">
|
||||
{{ currentFeedback.task.assignee.name }} (ID: {{ currentFeedback.task.assignee.id }})
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理人电话" v-if="currentFeedback.task.assignee">
|
||||
{{ currentFeedback.task.assignee.phone || '未提供' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="网格坐标" :span="2" v-if="currentFeedback.task.gridX !== null && currentFeedback.task.gridY !== null">
|
||||
X: {{ currentFeedback.task.gridX }}, Y: {{ currentFeedback.task.gridY }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分配时间" v-if="currentFeedback.task.assignment?.assignmentTime">
|
||||
{{ formatDate(currentFeedback.task.assignment.assignmentTime) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分配说明" v-if="currentFeedback.task.assignment?.remarks">
|
||||
{{ currentFeedback.task.assignment.remarks }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 任务提交详情 -->
|
||||
<div class="submission-details mt-4" v-if="currentFeedback.task && currentFeedback.task.submissionInfo">
|
||||
<h4>任务提交详情</h4>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="提交说明">
|
||||
{{ currentFeedback.task.submissionInfo.comments }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提交附件" v-if="currentFeedback.task.submissionInfo.attachments && currentFeedback.task.submissionInfo.attachments.length > 0">
|
||||
<div v-for="att in currentFeedback.task.submissionInfo.attachments" :key="att.id">
|
||||
<el-link :href="att.url" type="primary" target="_blank" :icon="Document">{{ att.fileName }}</el-link>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="无附件" v-else>
|
||||
未提交任何附件
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="image-gallery mt-4" v-if="currentFeedback.attachments && currentFeedback.attachments.length > 0">
|
||||
<h4>相关图片</h4>
|
||||
<el-image
|
||||
v-for="att in currentFeedback.attachments"
|
||||
:key="att.id"
|
||||
:src="att.url"
|
||||
:preview-src-list="currentFeedback.attachments.map(a => a.url)"
|
||||
:initial-index="currentFeedback.attachments.findIndex(a => a.id === att.id)"
|
||||
fit="cover"
|
||||
class="feedback-image"
|
||||
lazy
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!loading && !currentFeedback" class="empty-state">
|
||||
<el-empty description="无法加载反馈详情" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
<!-- 任务审批按钮 -->
|
||||
<template v-if="currentFeedback?.task?.status === 'SUBMITTED' && canManageSubmittedTask">
|
||||
<el-button type="success" @click="handleApprove" :loading="approving">批准通过</el-button>
|
||||
<el-button type="danger" @click="handleRejectTask" :loading="rejecting">打回任务</el-button>
|
||||
</template>
|
||||
<!-- 根据状态条件性显示操作按钮 -->
|
||||
<el-button type="primary" @click="handleProcess" v-if="canProcess">处理</el-button>
|
||||
<el-button type="success" @click="handleAssign" v-if="canAssign">分配任务</el-button>
|
||||
<el-button type="warning" @click="handleRejectFeedback" v-if="canReject">拒绝反馈</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 分配任务对话框 -->
|
||||
<el-dialog
|
||||
v-model="assignDialogVisible"
|
||||
title="分配任务"
|
||||
width="30%"
|
||||
:before-close="() => assignDialogVisible = false"
|
||||
>
|
||||
<div v-if="loadingGridWorkers">正在加载网格员...</div>
|
||||
<el-select
|
||||
v-else
|
||||
v-model="selectedGridWorkerId"
|
||||
placeholder="请选择网格员"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-option
|
||||
v-for="worker in gridWorkers"
|
||||
:key="worker.id"
|
||||
:label="`${worker.name} (ID: ${worker.id})${worker.phone ? ' - ' + worker.phone : ''}`"
|
||||
:value="worker.id"
|
||||
/>
|
||||
</el-select>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="assignDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmAssign" :disabled="!selectedGridWorkerId">
|
||||
确认分配
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted, h } from 'vue';
|
||||
import { useFeedbackStore } from '@/store/feedback';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ElMessage, ElMessageBox, ElSelect, ElOption } from 'element-plus';
|
||||
import { FeedbackStatus, PollutionType, SeverityLevel, processFeedback, assignFeedback, rejectFeedback, resolveFeedback } from '@/api/feedback';
|
||||
import { approveTask, rejectTask } from '@/api/tasks';
|
||||
import type { UserAccount } from '@/api/types';
|
||||
import apiClient from '@/api/index';
|
||||
import { Document, Refresh } from '@element-plus/icons-vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
feedbackId: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['close', 'refresh']);
|
||||
|
||||
const feedbackStore = useFeedbackStore();
|
||||
const { currentFeedbackDetail: currentFeedback, detailLoading: loading } = storeToRefs(feedbackStore);
|
||||
const authStore = useAuthStore();
|
||||
const currentUser = computed(() => authStore.user);
|
||||
|
||||
const approving = ref(false);
|
||||
const rejecting = ref(false);
|
||||
|
||||
// 添加网格员列表状态
|
||||
const gridWorkers = ref<UserAccount[]>([]);
|
||||
const loadingGridWorkers = ref(false);
|
||||
const selectedGridWorkerId = ref<number | null>(null);
|
||||
const assignDialogVisible = ref(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (props.feedbackId) {
|
||||
feedbackStore.fetchFeedbackDetail(props.feedbackId);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载网格员列表
|
||||
const loadGridWorkers = async () => {
|
||||
loadingGridWorkers.value = true;
|
||||
try {
|
||||
// 直接调用API
|
||||
const response = await apiClient.get('/tasks/grid-workers');
|
||||
// 根据apiClient的拦截器,response已经是处理过的数据了
|
||||
gridWorkers.value = response as unknown as UserAccount[];
|
||||
console.log('成功获取到网格员列表:', gridWorkers.value);
|
||||
if (gridWorkers.value.length === 0) {
|
||||
ElMessage.info('当前没有可用的网格员。');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载网格员列表失败:', error);
|
||||
ElMessage.error('加载网格员列表失败,请检查后端服务是否正常。');
|
||||
gridWorkers.value = []; // 失败时清空数组
|
||||
} finally {
|
||||
loadingGridWorkers.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取模拟网格员数据
|
||||
const getMockGridWorkers = (): UserAccount[] => {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
name: '张三',
|
||||
email: 'zhangsan@example.com',
|
||||
phone: '13800000001',
|
||||
role: 'GRID_WORKER',
|
||||
status: 'ACTIVE',
|
||||
gender: 'MALE',
|
||||
region: '北京市朝阳区',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '李四',
|
||||
email: 'lisi@example.com',
|
||||
phone: '13800000002',
|
||||
role: 'GRID_WORKER',
|
||||
status: 'ACTIVE',
|
||||
gender: 'FEMALE',
|
||||
region: '北京市海淀区',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '王五',
|
||||
email: 'wangwu@example.com',
|
||||
phone: '13800000003',
|
||||
role: 'GRID_WORKER',
|
||||
status: 'ACTIVE',
|
||||
gender: 'MALE',
|
||||
region: '北京市西城区',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// 在对话框显示时加载网格员列表
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && canAssign.value) {
|
||||
// 移除这里的加载,改为在点击分配按钮时加载
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// --- Computed Properties for Action Buttons ---
|
||||
|
||||
const canManageSubmittedTask = computed(() => {
|
||||
// Per user requirements, only ADMINs can manage tasks submitted by Grid Workers.
|
||||
return authStore.user?.role === 'ADMIN';
|
||||
});
|
||||
|
||||
const isActionableBySupervisor = computed(() => {
|
||||
// This property now simply checks if the user has the authority to perform
|
||||
// pre-assignment actions like processing or rejecting feedback.
|
||||
if (!authStore.user) return false;
|
||||
const viewerRole = authStore.user.role;
|
||||
return viewerRole === 'ADMIN' || viewerRole === 'SUPERVISOR';
|
||||
});
|
||||
|
||||
const canProcess = computed(() => {
|
||||
if (!currentFeedback.value || !isActionableBySupervisor.value) return false;
|
||||
const processableStatus = [
|
||||
FeedbackStatus.AI_REVIEWING,
|
||||
FeedbackStatus.PENDING_REVIEW,
|
||||
FeedbackStatus.CONFIRMED,
|
||||
];
|
||||
return processableStatus.includes(currentFeedback.value.status);
|
||||
});
|
||||
|
||||
const canAssign = computed(() => {
|
||||
if (!currentFeedback.value || !authStore.user) return false;
|
||||
|
||||
const viewerRole = authStore.user.role;
|
||||
const isEligibleRole = viewerRole === 'ADMIN' || viewerRole === 'SUPERVISOR';
|
||||
if (!isEligibleRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// As per user feedback, supervisors and admins should be able to assign tasks.
|
||||
// The button should appear when the feedback is ready for assignment.
|
||||
// Based on the 'handleProcess' function, this status is PENDING_ASSIGNMENT.
|
||||
return currentFeedback.value.status === 'PENDING_ASSIGNMENT';
|
||||
});
|
||||
|
||||
const canReject = computed(() => {
|
||||
if (!currentFeedback.value || !isActionableBySupervisor.value) return false;
|
||||
const rejectableStatus = [
|
||||
FeedbackStatus.AI_REVIEWING,
|
||||
FeedbackStatus.PENDING_REVIEW,
|
||||
];
|
||||
return rejectableStatus.includes(currentFeedback.value.status);
|
||||
});
|
||||
|
||||
// 格式化显示内容的辅助函数
|
||||
const formatPollutionType = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PM25': 'PM2.5',
|
||||
'O3': '臭氧',
|
||||
'NO2': '二氧化氮',
|
||||
'SO2': '二氧化硫',
|
||||
'OTHER': '其他'
|
||||
};
|
||||
return map[type] || type;
|
||||
};
|
||||
|
||||
const formatSeverityLevel = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'LOW': '低',
|
||||
'MEDIUM': '中',
|
||||
'HIGH': '高'
|
||||
};
|
||||
return map[level] || level;
|
||||
};
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'AI_REVIEWING': 'AI审核中',
|
||||
'PENDING_REVIEW': '待人工审核',
|
||||
'REJECTED': '已拒绝',
|
||||
'CONFIRMED': '已确认',
|
||||
'ASSIGNED': '已分配',
|
||||
'IN_PROGRESS': '处理中',
|
||||
'RESOLVED': '已处理',
|
||||
'CLOSED': '已关闭',
|
||||
'PENDING_ASSIGNMENT': '待分配',
|
||||
'PROCESSED': '已处理',
|
||||
'COMPLETED': '已处理',
|
||||
'SUBMITTED': '已提交'
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getSeverityTagType = (level: string) => {
|
||||
const map: Record<string, 'info' | 'warning' | 'danger'> = {
|
||||
'LOW': 'info',
|
||||
'MEDIUM': 'warning',
|
||||
'HIGH': 'danger'
|
||||
};
|
||||
return map[level] || 'info';
|
||||
};
|
||||
|
||||
const getStatusTagType = (status: string) => {
|
||||
const map: Record<string, 'info' | 'warning' | 'danger' | 'primary' | 'success'> = {
|
||||
'AI_REVIEWING': 'info',
|
||||
'PENDING_REVIEW': 'warning',
|
||||
'REJECTED': 'danger',
|
||||
'CONFIRMED': 'primary',
|
||||
'ASSIGNED': 'primary',
|
||||
'IN_PROGRESS': 'warning',
|
||||
'RESOLVED': 'success',
|
||||
'CLOSED': 'info',
|
||||
'PENDING_ASSIGNMENT': 'warning',
|
||||
'PROCESSED': 'success',
|
||||
'COMPLETED': 'success',
|
||||
'SUBMITTED': 'info'
|
||||
};
|
||||
return map[status] || 'info';
|
||||
};
|
||||
|
||||
const getFeedbackDisplayStatus = (feedback: any): string => {
|
||||
if (!feedback) return '未知状态';
|
||||
if (feedback.task && feedback.task.status === 'COMPLETED') {
|
||||
return '已处理';
|
||||
}
|
||||
return formatStatus(feedback.status);
|
||||
};
|
||||
|
||||
const getFeedbackDisplayTagType = (feedback: any): 'info' | 'warning' | 'danger' | 'primary' | 'success' => {
|
||||
if (!feedback) return 'info';
|
||||
if (feedback.task && feedback.task.status === 'COMPLETED') {
|
||||
return 'success';
|
||||
}
|
||||
return getStatusTagType(feedback.status);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch (e) {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const formatRole = (role?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'ADMIN': '管理员',
|
||||
'DECISION_MAKER': '决策人',
|
||||
'SUPERVISOR': '监督员',
|
||||
'GRID_WORKER': '网格员',
|
||||
'PUBLIC_SUPERVISOR': '公众监督员'
|
||||
};
|
||||
if (!role) return '未知';
|
||||
return map[role] || role;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.feedbackId,
|
||||
(newId) => {
|
||||
if (newId && props.visible) {
|
||||
feedbackStore.fetchFeedbackDetail(newId);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
// 清空选中的网格员
|
||||
selectedGridWorkerId.value = null;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (!currentFeedback.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'此操作将批准反馈并创建一个待分配的任务。',
|
||||
'确认处理反馈',
|
||||
{
|
||||
confirmButtonText: '继续',
|
||||
cancelButtonText: '取消',
|
||||
type: 'success',
|
||||
}
|
||||
);
|
||||
await processFeedback(currentFeedback.value.id, {
|
||||
status: FeedbackStatus.PENDING_ASSIGNMENT,
|
||||
notes: '管理员已批准该反馈,准备分配任务。'
|
||||
});
|
||||
ElMessage.success('反馈已批准,进入待分配状态');
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('处理反馈失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (!currentFeedback.value) return;
|
||||
loadGridWorkers();
|
||||
assignDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const confirmAssign = async () => {
|
||||
if (!currentFeedback.value || !selectedGridWorkerId.value) {
|
||||
ElMessage.warning('请选择一个网格员');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post('/tasks/assign', {
|
||||
feedbackId: currentFeedback.value.id,
|
||||
assigneeId: selectedGridWorkerId.value
|
||||
});
|
||||
|
||||
ElMessage.success('分配成功');
|
||||
assignDialogVisible.value = false;
|
||||
emit('refresh');
|
||||
handleClose();
|
||||
} catch (apiError) {
|
||||
console.error('API分配失败:', apiError);
|
||||
ElMessage.error('分配失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectTask = async () => {
|
||||
const taskId = currentFeedback.value?.task?.id;
|
||||
if (!taskId) {
|
||||
ElMessage.error('无法获取任务ID,操作取消');
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessageBox.prompt('请输入打回任务的理由:', '打回任务', {
|
||||
confirmButtonText: '确认打回',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /.+/,
|
||||
inputErrorMessage: '理由不能为空',
|
||||
})
|
||||
.then(async ({ value }) => {
|
||||
rejecting.value = true;
|
||||
try {
|
||||
await rejectTask(taskId, value);
|
||||
ElMessage.success('任务已打回');
|
||||
emit('refresh');
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('打回任务失败:', error);
|
||||
ElMessage.error('操作失败,请重试');
|
||||
} finally {
|
||||
rejecting.value = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.info('已取消操作');
|
||||
});
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!currentFeedback.value?.task) return;
|
||||
approving.value = true;
|
||||
try {
|
||||
await approveTask(currentFeedback.value.task.id);
|
||||
ElMessage.success('任务已批准');
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
ElMessage.error('批准任务失败');
|
||||
} finally {
|
||||
approving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectFeedback = async () => {
|
||||
if (!currentFeedback.value) return;
|
||||
if (props.feedbackId) {
|
||||
ElMessageBox.prompt('请输入拒绝此反馈的理由:', '拒绝反馈', {
|
||||
confirmButtonText: '确认拒绝',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
.then(async ({ value }) => {
|
||||
try {
|
||||
await rejectFeedback(props.feedbackId!, { notes: value || '无明确理由' });
|
||||
ElMessage.success('反馈已拒绝');
|
||||
emit('refresh');
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('拒绝反馈失败:', error);
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.info('已取消拒绝操作');
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feedback-detail-dialog .loading-container {
|
||||
height: 300px;
|
||||
}
|
||||
.detail-container {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.description-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.image-gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.feedback-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
333
ems-frontend/src/components/GridMap.vue
Normal file
333
ems-frontend/src/components/GridMap.vue
Normal file
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="grid-map-container">
|
||||
<div class="map-header">
|
||||
<h3>环境监测网格地图</h3>
|
||||
<div class="map-controls">
|
||||
<el-select v-model="selectedCity" placeholder="选择城市" @change="handleCityChange" size="small">
|
||||
<el-option v-for="city in cities" :key="city" :label="city" :value="city" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" :icon="Loading" :loading="loading" @click="refreshData">
|
||||
刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-content">
|
||||
<div class="map-container" ref="mapContainer">
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<el-icon class="loading-icon" :size="32"><Loading /></el-icon>
|
||||
<div class="loading-text">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-legend">
|
||||
<div v-for="city in Object.keys(cityColorPalette)" :key="city" class="legend-item">
|
||||
<div class="legend-color" :style="{ backgroundColor: cityColorPalette[city] }"></div>
|
||||
<span>{{ city }}</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #333333;"></div>
|
||||
<span>障碍区域</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网格编辑/详情一体化对话框 -->
|
||||
<el-dialog
|
||||
v-model="gridEditDialogVisible"
|
||||
:title="editingGrid ? `编辑网格 (${editingGrid.gridX}, ${editingGrid.gridY})` : '网格详情'"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form v-if="editingGrid" :model="editFormData" label-width="120px">
|
||||
<el-descriptions :column="1" border class="detail-view" v-if="!isEditMode">
|
||||
<el-descriptions-item label="城市">{{ editingGrid.cityName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="区域">{{ editingGrid.districtName || '未指定' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="editingGrid.isObstacle ? 'danger' : currentWorkerInDialog ? 'success' : 'warning'" effect="dark">
|
||||
{{ editingGrid.isObstacle ? '障碍区域' : currentWorkerInDialog ? '已分配' : '未分配' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述">{{ editFormData.description || '无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网格员">
|
||||
<div v-if="currentWorkerInDialog">
|
||||
<strong>{{ currentWorkerInDialog.name }}</strong> (ID: {{ currentWorkerInDialog.id }})
|
||||
</div>
|
||||
<div v-else>未分配</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="isEditMode">
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="editFormData.description" type="textarea" :rows="3" placeholder="请输入网格描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否为障碍物">
|
||||
<el-switch v-model="editFormData.isObstacle" />
|
||||
</el-form-item>
|
||||
<div v-if="!editFormData.isObstacle">
|
||||
<el-divider>网格员分配</el-divider>
|
||||
<el-form-item label="分配网格员">
|
||||
<el-select v-model="editFormData.workerId" placeholder="选择或搜索网格员" clearable filterable style="width: 100%;">
|
||||
<el-option
|
||||
v-for="worker in availableGridWorkers"
|
||||
:key="worker.id"
|
||||
:label="`${worker.name} (ID: ${worker.id})`"
|
||||
:value="worker.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="isEditMode = !isEditMode">{{ isEditMode ? '返回详情' : '编辑网格' }}</el-button>
|
||||
<el-button @click="gridEditDialogVisible = false">关闭</el-button>
|
||||
<el-button v-if="isEditMode" type="primary" @click="handleUpdateGrid" :loading="loading">保存</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import {
|
||||
getGrids,
|
||||
getGridWorkers,
|
||||
updateGrid,
|
||||
assignGridWorkerByCoordinates,
|
||||
removeGridWorkerByCoordinates
|
||||
} from '@/api/grid';
|
||||
import type { GridData, GridWorker } from '@/api/grid';
|
||||
import {
|
||||
ElMessage,
|
||||
ElDialog,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElButton,
|
||||
ElDivider,
|
||||
ElTag,
|
||||
ElSelect,
|
||||
ElOption,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElSwitch,
|
||||
ElAlert
|
||||
} from 'element-plus';
|
||||
import { Loading } from '@element-plus/icons-vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const selectedCity = ref<string>('北京市');
|
||||
const cities = ref<string[]>(['北京市']);
|
||||
const mapContainer = ref<HTMLDivElement | null>(null);
|
||||
const gridData = ref<GridData[]>([]);
|
||||
const gridWorkers = ref<GridWorker[]>([]);
|
||||
const availableGridWorkers = ref<GridWorker[]>([]);
|
||||
|
||||
const gridEditDialogVisible = ref(false);
|
||||
const editingGrid = ref<GridData | null>(null);
|
||||
const isEditMode = ref(false);
|
||||
|
||||
const editFormData = ref({
|
||||
description: '',
|
||||
isObstacle: false,
|
||||
workerId: null as number | string | null,
|
||||
});
|
||||
|
||||
const currentWorkerInDialog = computed(() => {
|
||||
if (!editingGrid.value) return null;
|
||||
return gridWorkers.value.find(w => w.gridX === editingGrid.value!.gridX && w.gridY === editingGrid.value!.gridY) || null;
|
||||
});
|
||||
|
||||
let gridSize = 20;
|
||||
const cityColorPalette: Record<string, string> = {};
|
||||
|
||||
const getCityColor = (cityName: string): string => {
|
||||
if (!cityColorPalette[cityName]) {
|
||||
const predefinedColors = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'];
|
||||
const allCityNames = [...new Set(gridData.value.map(g => g.cityName))];
|
||||
const cityIndex = allCityNames.indexOf(cityName);
|
||||
cityColorPalette[cityName] = predefinedColors[cityIndex % predefinedColors.length];
|
||||
}
|
||||
return cityColorPalette[cityName];
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [grids, workers] = await Promise.all([
|
||||
getGrids(),
|
||||
getGridWorkers()
|
||||
]);
|
||||
|
||||
if (Array.isArray(grids)) {
|
||||
gridData.value = grids;
|
||||
const uniqueCities = [...new Set(grids.map(g => g.cityName))];
|
||||
if (uniqueCities.length > 0) {
|
||||
cities.value = uniqueCities;
|
||||
selectedCity.value = uniqueCities[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(workers)) {
|
||||
gridWorkers.value = workers;
|
||||
}
|
||||
|
||||
renderMap();
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
ElMessage.error('加载数据失败,请检查后端服务');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailableGridWorkers = async () => {
|
||||
try {
|
||||
const response = await getGridWorkers({ unassigned: true });
|
||||
availableGridWorkers.value = Array.isArray(response) ? response : [];
|
||||
} catch (error) {
|
||||
console.error('获取可用网格员数据失败:', error);
|
||||
ElMessage.error('获取可用网格员列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const renderMap = () => {
|
||||
const container = mapContainer.value;
|
||||
if (!container || !gridData.value || gridData.value.length === 0) {
|
||||
if (container) container.innerHTML = '<div class="empty-state">没有可显示的网格数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
const allX = gridData.value.map(g => g.gridX);
|
||||
const allY = gridData.value.map(g => g.gridY);
|
||||
const minX = Math.min(...allX);
|
||||
const maxX = Math.max(...allX);
|
||||
const minY = Math.min(...allY);
|
||||
const maxY = Math.max(...allY);
|
||||
const worldWidth = maxX - minX + 1;
|
||||
const worldHeight = maxY - minY + 1;
|
||||
gridSize = Math.max(5, Math.floor(Math.min(container.clientWidth / worldWidth, container.clientHeight / worldHeight)));
|
||||
const svgWidth = worldWidth * gridSize;
|
||||
const svgHeight = worldHeight * gridSize;
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', `${svgWidth}px`);
|
||||
svg.setAttribute('height', `${svgHeight}px`);
|
||||
svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
|
||||
|
||||
gridData.value.forEach(grid => {
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
rect.setAttribute('x', `${(grid.gridX - minX) * gridSize}`);
|
||||
rect.setAttribute('y', `${(grid.gridY - minY) * gridSize}`);
|
||||
rect.setAttribute('width', `${gridSize}`);
|
||||
rect.setAttribute('height', `${gridSize}`);
|
||||
rect.setAttribute('fill', grid.isObstacle ? '#333333' : getCityColor(grid.cityName));
|
||||
rect.setAttribute('stroke-width', '0.5');
|
||||
rect.setAttribute('stroke', '#fff');
|
||||
rect.setAttribute('cursor', 'pointer');
|
||||
rect.addEventListener('click', () => showGridDialog(grid));
|
||||
svg.appendChild(rect);
|
||||
});
|
||||
container.appendChild(svg);
|
||||
};
|
||||
|
||||
const showGridDialog = async (grid: GridData) => {
|
||||
isEditMode.value = false;
|
||||
editingGrid.value = grid;
|
||||
const currentWorker = gridWorkers.value.find(w => w.gridX === grid.gridX && w.gridY === grid.gridY);
|
||||
|
||||
editFormData.value = {
|
||||
description: grid.description || '',
|
||||
isObstacle: grid.isObstacle,
|
||||
workerId: currentWorker ? currentWorker.id : '',
|
||||
};
|
||||
|
||||
await fetchAvailableGridWorkers();
|
||||
gridEditDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleUpdateGrid = async () => {
|
||||
if (!editingGrid.value) return;
|
||||
|
||||
loading.value = true;
|
||||
const grid = editingGrid.value;
|
||||
const { isObstacle, description, workerId } = editFormData.value;
|
||||
const newWorkerId = workerId ? Number(workerId) : null;
|
||||
|
||||
try {
|
||||
await updateGrid({ ...grid, isObstacle, description });
|
||||
|
||||
const workerAssignmentChanged = (currentWorkerInDialog.value?.id ?? null) !== newWorkerId;
|
||||
|
||||
if (isObstacle && currentWorkerInDialog.value) {
|
||||
await removeGridWorkerByCoordinates(grid.gridX, grid.gridY);
|
||||
} else if (!isObstacle && workerAssignmentChanged) {
|
||||
if (newWorkerId) {
|
||||
await assignGridWorkerByCoordinates(grid.gridX, grid.gridY, newWorkerId);
|
||||
} else if (currentWorkerInDialog.value) {
|
||||
await removeGridWorkerByCoordinates(grid.gridX, grid.gridY);
|
||||
}
|
||||
}
|
||||
ElMessage.success('网格信息更新成功');
|
||||
gridEditDialogVisible.value = false;
|
||||
await loadData();
|
||||
} catch (error: any) {
|
||||
ElMessage.error('更新网格失败: ' + (error?.response?.data?.message || error.message));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = () => loadData();
|
||||
const handleCityChange = () => { /* Future implementation for city-specific view */ };
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
window.addEventListener('resize', renderMap);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', renderMap);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-map-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.map-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.map-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
.map-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; }
|
||||
.legend-color { width: 16px; height: 16px; margin-right: 8px; border-radius: 4px; }
|
||||
.dialog-footer { text-align: right; }
|
||||
.detail-view { margin-bottom: 20px; }
|
||||
</style>
|
||||
99
ems-frontend/src/components/SubmitProgressDialog.vue
Normal file
99
ems-frontend/src/components/SubmitProgressDialog.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="提交任务进度"
|
||||
width="50%"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" label-width="80px">
|
||||
<el-form-item label="进度说明" prop="comments">
|
||||
<el-input
|
||||
v-model="form.comments"
|
||||
type="textarea"
|
||||
rows="4"
|
||||
placeholder="请输入详细的进度说明..."
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传图片" prop="files">
|
||||
<el-upload
|
||||
v-model:file-list="form.files"
|
||||
action="#"
|
||||
list-type="picture-card"
|
||||
:auto-upload="false"
|
||||
multiple
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="$emit('update:visible', false)">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="loading">
|
||||
提交
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import type { FormInstance, UploadUserFile } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import apiClient from '@/api';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
taskId: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:visible', 'submitted']);
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const loading = ref(false);
|
||||
const form = reactive({
|
||||
comments: '',
|
||||
files: [] as UploadUserFile[],
|
||||
});
|
||||
|
||||
watch(() => props.visible, (newValue) => {
|
||||
if (newValue) {
|
||||
formRef.value?.resetFields();
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!props.taskId) {
|
||||
ElMessage.error('任务ID无效');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('comments', form.comments);
|
||||
form.files.forEach(file => {
|
||||
if (file.raw) {
|
||||
formData.append('files', file.raw);
|
||||
}
|
||||
});
|
||||
|
||||
await apiClient.post(`/worker/${props.taskId}/submit`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
ElMessage.success('进度提交成功');
|
||||
emit('submitted');
|
||||
emit('update:visible', false);
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
ElMessage.error('提交失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
27
ems-frontend/src/constants/pollution.ts
Normal file
27
ems-frontend/src/constants/pollution.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @file Pollution related constants, aligned with backend enums.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents the types of pollution, consistent with the `PollutionType` enum in the backend.
|
||||
* This ensures that frontend displays and data submissions are aligned with backend expectations.
|
||||
*/
|
||||
export const POLLUTION_TYPES = ['PM25', 'O3', 'NO2', 'SO2', 'OTHER'] as const;
|
||||
|
||||
/**
|
||||
* Type definition for a single pollution type.
|
||||
* Ensures type safety when working with pollution constants.
|
||||
*/
|
||||
export type PollutionType = typeof POLLUTION_TYPES[number];
|
||||
|
||||
/**
|
||||
* A map to provide human-readable names for pollution types.
|
||||
* Useful for displaying in UI components like chart legends or labels.
|
||||
*/
|
||||
export const POLLUTION_TYPE_MAP: Record<PollutionType, string> = {
|
||||
PM25: 'PM2.5',
|
||||
O3: 'O₃',
|
||||
NO2: 'NO₂',
|
||||
SO2: 'SO₂',
|
||||
OTHER: '其他',
|
||||
};
|
||||
428
ems-frontend/src/layouts/MainLayout.vue
Normal file
428
ems-frontend/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<el-container class="layout-container" :class="layoutClass">
|
||||
<el-header class="layout-header">
|
||||
<div class="header-title">环境监督系统</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="el-dropdown-link">
|
||||
{{ userInfoDisplay }}
|
||||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-container class="main-body-container">
|
||||
<el-aside width="220px" class="layout-aside">
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
class="el-menu-vertical"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<template v-for="menu in visibleMenu" :key="menu.path">
|
||||
<el-sub-menu v-if="menu.children && menu.children.length > 0" :index="menu.path">
|
||||
<template #title>
|
||||
<el-icon><component :is="menu.meta.icon" /></el-icon>
|
||||
<span>{{ menu.meta.title }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="child in menu.children" :key="child.path" :index="child.path">
|
||||
{{ child.meta.title }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item v-else :index="menu.path">
|
||||
<el-icon><component :is="menu.meta.icon" /></el-icon>
|
||||
<span>{{ menu.meta.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main class="layout-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
HomeFilled,
|
||||
InfoFilled,
|
||||
ArrowDown,
|
||||
Location,
|
||||
List,
|
||||
ChatDotSquare,
|
||||
Setting,
|
||||
User,
|
||||
DataAnalysis,
|
||||
Grid,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { computed, ref, markRaw, watch } from 'vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const userRole = computed(() => authStore.user?.role);
|
||||
|
||||
const layoutClass = computed(() => {
|
||||
if (userRole.value) {
|
||||
// a simple convention would be `role-<role-name-in-lowercase>`
|
||||
return `role-${userRole.value.toLowerCase()}`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath, oldPath) => {
|
||||
console.log(`路由从 ${oldPath} 切换到 ${newPath}`);
|
||||
}
|
||||
);
|
||||
|
||||
const allMenus = ref([
|
||||
{
|
||||
path: '/dashboard',
|
||||
meta: { title: '仪表盘', icon: markRaw(DataAnalysis), roles: ['DECISION_MAKER', 'ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: '/map',
|
||||
meta: { title: '网格地图', icon: markRaw(Grid), roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER'] }
|
||||
},
|
||||
{
|
||||
path: '/my-tasks',
|
||||
meta: { title: '我的任务', icon: markRaw(List), roles: ['GRID_WORKER'] }
|
||||
},
|
||||
{
|
||||
path: '/worker-map',
|
||||
meta: { title: '地图管理', icon: markRaw(Grid), roles: ['GRID_WORKER'] }
|
||||
},
|
||||
{
|
||||
path: '/submit-feedback',
|
||||
meta: { title: '提交反馈', icon: markRaw(ChatDotSquare), roles: ['PUBLIC_SUPERVISOR'] }
|
||||
},
|
||||
{
|
||||
path: '/feedback',
|
||||
meta: { title: '我的反馈', icon: markRaw(List), roles: ['PUBLIC_SUPERVISOR'] }
|
||||
},
|
||||
{
|
||||
path: '/feedback',
|
||||
meta: { title: '反馈管理', icon: markRaw(ChatDotSquare), roles: ['ADMIN', 'SUPERVISOR'] }
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
meta: { title: '系统管理', icon: markRaw(Setting), roles: ['ADMIN'] },
|
||||
children: [
|
||||
{ path: '/system/users', meta: { title: '人员管理', roles: ['ADMIN'] } },
|
||||
{ path: '/system/roles', meta: { title: '操作日志', roles: ['ADMIN'] } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
meta: { title: '设置', icon: markRaw(User), roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] },
|
||||
children: [
|
||||
{ path: '/settings/profile', meta: { title: '个人中心', roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] } },
|
||||
{ path: '/settings/my-logs', meta: { title: '我的操作日志', roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] } },
|
||||
{ path: '/settings/system', meta: { title: '系统设置', roles: ['ADMIN'] } },
|
||||
]
|
||||
},
|
||||
]);
|
||||
|
||||
const visibleMenu = computed(() => {
|
||||
if (!userRole.value) {
|
||||
return [];
|
||||
}
|
||||
const role = userRole.value;
|
||||
|
||||
function filterMenu(menuItems: any[]) {
|
||||
const accessibleMenu: any[] = [];
|
||||
for (const item of menuItems) {
|
||||
if (item.meta && item.meta.roles && item.meta.roles.includes(role)) {
|
||||
const newItem = { ...item };
|
||||
if (item.children) {
|
||||
newItem.children = filterMenu(item.children);
|
||||
if (newItem.children.length > 0 || newItem.path === '/settings') {
|
||||
accessibleMenu.push(newItem);
|
||||
}
|
||||
} else {
|
||||
accessibleMenu.push(newItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
return accessibleMenu;
|
||||
}
|
||||
|
||||
return filterMenu(allMenus.value);
|
||||
});
|
||||
|
||||
const userInfoDisplay = computed(() => {
|
||||
if (authStore.user) {
|
||||
return `${authStore.user.name} (${authStore.user.role})`;
|
||||
}
|
||||
return '未登录';
|
||||
});
|
||||
|
||||
const handleCommand = (command: string | number | object) => {
|
||||
if (command === 'logout') {
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuSelect = (path: string) => {
|
||||
console.log('菜单选择:', path);
|
||||
if (route.path === '/grid-management' && path !== '/grid-management') {
|
||||
console.log('从网格管理页面切换到其他页面,使用location.href');
|
||||
window.location.href = path;
|
||||
} else {
|
||||
router.push(path);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* The background color will be set by role-specific styles */
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
/* background: #ffffff; */ /* Will be set by role-specific styles */
|
||||
color: #303133;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 22px;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,.08);
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
/* color: #004d40; */ /* Will be set by role-specific styles */
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-dropdown-link {
|
||||
cursor: pointer;
|
||||
color: #606266;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.main-body-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
/* background-color: #ffffff; */ /* Will be set by role-specific styles */
|
||||
box-shadow: 2px 0 6px rgba(0,21,41,.08);
|
||||
transition: width 0.3s;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.el-menu-item, .el-sub-menu__title {
|
||||
/* color: #303133; */ /* Will be set by role-specific styles */
|
||||
font-size: 15px;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
.el-menu-item:hover, .el-sub-menu__title:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
/* background-color: #ecf5ff; */ /* Will be set by role-specific styles */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
padding: 20px;
|
||||
background-color: #f0f2f5;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* fade-transform */
|
||||
.fade-transform-leave-active,
|
||||
.fade-transform-enter-active {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.fade-transform-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-transform-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Role-based theming */
|
||||
|
||||
/* ADMIN THEME (Dark Green) */
|
||||
.role-admin .layout-header {
|
||||
background-color: #2E7D32; /* Dark Green */
|
||||
color: white;
|
||||
}
|
||||
.role-admin .header-title {
|
||||
color: white;
|
||||
}
|
||||
.role-admin .el-dropdown-link {
|
||||
color: white;
|
||||
}
|
||||
.role-admin .layout-aside {
|
||||
background-color: #388E3C; /* Slightly lighter green */
|
||||
}
|
||||
.role-admin .el-menu-item,
|
||||
.role-admin .el-sub-menu__title {
|
||||
color: #E8F5E9; /* Muted green text */
|
||||
}
|
||||
.role-admin .el-menu-item:hover,
|
||||
.role-admin .el-sub-menu__title:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
.role-admin .el-menu-item.is-active {
|
||||
background-color: #2E7D32; /* Dark Green */
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
/* DECISION_MAKER THEME (Corporate Green) */
|
||||
.role-decision_maker .layout-header {
|
||||
background-color: #00695C; /* Teal/Corporate Green */
|
||||
color: white;
|
||||
}
|
||||
.role-decision_maker .header-title {
|
||||
color: white;
|
||||
}
|
||||
.role-decision_maker .el-dropdown-link {
|
||||
color: white;
|
||||
}
|
||||
.role-decision_maker .layout-aside {
|
||||
background-color: #00796B;
|
||||
}
|
||||
.role-decision_maker .el-menu-item,
|
||||
.role-decision_maker .el-sub-menu__title {
|
||||
color: #B2DFDB;
|
||||
}
|
||||
.role-decision_maker .el-menu-item:hover,
|
||||
.role-decision_maker .el-sub-menu__title:hover {
|
||||
background-color: #00897b;
|
||||
}
|
||||
.role-decision_maker .el-menu-item.is-active {
|
||||
background-color: #00695C;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
/* GRID_WORKER THEME (Vibrant Green) */
|
||||
.role-grid_worker .layout-header {
|
||||
background-color: #689F38; /* Vibrant Green */
|
||||
color: white;
|
||||
}
|
||||
.role-grid_worker .header-title {
|
||||
color: white;
|
||||
}
|
||||
.role-grid_worker .el-dropdown-link {
|
||||
color: white;
|
||||
}
|
||||
.role-grid_worker .layout-aside {
|
||||
background-color: #7CB342;
|
||||
}
|
||||
.role-grid_worker .el-menu-item,
|
||||
.role-grid_worker .el-sub-menu__title {
|
||||
color: #F1F8E9;
|
||||
}
|
||||
.role-grid_worker .el-menu-item:hover,
|
||||
.role-grid_worker .el-sub-menu__title:hover {
|
||||
background-color: #8bc34a;
|
||||
}
|
||||
.role-grid_worker .el-menu-item.is-active {
|
||||
background-color: #689F38;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* SUPERVISOR THEME (Calming Green) */
|
||||
.role-supervisor .layout-header {
|
||||
background-color: #39796b; /* Calming Green */
|
||||
color: white;
|
||||
}
|
||||
.role-supervisor .header-title {
|
||||
color: white;
|
||||
}
|
||||
.role-supervisor .el-dropdown-link {
|
||||
color: white;
|
||||
}
|
||||
.role-supervisor .layout-aside {
|
||||
background-color: #4DB6AC;
|
||||
}
|
||||
.role-supervisor .el-menu-item,
|
||||
.role-supervisor .el-sub-menu__title {
|
||||
color: #E0F2F1;
|
||||
}
|
||||
.role-supervisor .el-menu-item:hover,
|
||||
.role-supervisor .el-sub-menu__title:hover {
|
||||
background-color: #26a69a;
|
||||
}
|
||||
.role-supervisor .el-menu-item.is-active {
|
||||
background-color: #39796b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* PUBLIC_SUPERVISOR THEME (Light Green) */
|
||||
.role-public_supervisor .layout-header {
|
||||
background-color: #AED581; /* Light Green */
|
||||
color: #333;
|
||||
}
|
||||
.role-public_supervisor .header-title {
|
||||
color: #333;
|
||||
}
|
||||
.role-public_supervisor .el-dropdown-link {
|
||||
color: #333;
|
||||
}
|
||||
.role-public_supervisor .layout-aside {
|
||||
background-color: #DCEDC8;
|
||||
}
|
||||
.role-public_supervisor .el-menu-item,
|
||||
.role-public_supervisor .el-sub-menu__title {
|
||||
color: #556B2F;
|
||||
}
|
||||
.role-public_supervisor .el-menu-item:hover,
|
||||
.role-public_supervisor .el-sub-menu__title:hover {
|
||||
background-color: #9ccc65;
|
||||
}
|
||||
.role-public_supervisor .el-menu-item.is-active {
|
||||
background-color: #AED581;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
33
ems-frontend/src/main.ts
Normal file
33
ems-frontend/src/main.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import './assets/main.css'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'animate.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
const elementPlusOptions = {
|
||||
locale: zhCn,
|
||||
zIndex: 3000,
|
||||
popperOptions: {
|
||||
modifiers: [
|
||||
{
|
||||
name: 'computeStyles',
|
||||
options: {
|
||||
gpuAcceleration: false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
} as any;
|
||||
|
||||
app.use(ElementPlus as any, elementPlusOptions)
|
||||
|
||||
app.mount('#app')
|
||||
163
ems-frontend/src/router/index.ts
Normal file
163
ems-frontend/src/router/index.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import MainLayout from '../layouts/MainLayout.vue'
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue')
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('../views/RegisterView.vue')
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('../views/ForgotPasswordView.vue')
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: () => import('../views/ResetPasswordView.vue')
|
||||
},
|
||||
{
|
||||
path: '/public-feedback',
|
||||
name: 'public-feedback',
|
||||
component: () => import('../views/PublicFeedbackSubmitView.vue')
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
redirect: '/dashboard',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
meta: { roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR'] }
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
name: 'map',
|
||||
component: () => import('../views/GridManagementView.vue'),
|
||||
meta: { roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER'] }
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'tasks',
|
||||
component: () => import('../views/TaskView.vue'),
|
||||
meta: { roles: ['ADMIN', 'SUPERVISOR'] }
|
||||
},
|
||||
{
|
||||
path: 'my-tasks',
|
||||
name: 'my-tasks',
|
||||
component: () => import('../views/MyTasksView.vue'),
|
||||
meta: { roles: ['GRID_WORKER'] }
|
||||
},
|
||||
{
|
||||
path: 'worker-map',
|
||||
name: 'worker-map',
|
||||
component: () => import('../views/WorkerMapView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['GRID_WORKER'] }
|
||||
},
|
||||
{
|
||||
path: 'my-tasks/:id',
|
||||
name: 'my-task-detail',
|
||||
component: () => import('../views/TaskDetailView.vue'),
|
||||
props: true,
|
||||
meta: { roles: ['GRID_WORKER'] }
|
||||
},
|
||||
{
|
||||
path: 'feedback',
|
||||
name: 'feedback',
|
||||
component: () => import('../views/FeedbackView.vue'),
|
||||
meta: { roles: ['PUBLIC_SUPERVISOR', 'ADMIN', 'SUPERVISOR'] }
|
||||
},
|
||||
{
|
||||
path: 'submit-feedback',
|
||||
name: 'submit-feedback',
|
||||
component: () => import('../views/SubmitFeedbackView.vue'),
|
||||
meta: { roles: ['PUBLIC_SUPERVISOR'] }
|
||||
},
|
||||
{
|
||||
path: 'system/users',
|
||||
name: 'system-users',
|
||||
component: () => import('../views/UserManagementView.vue'),
|
||||
meta: { roles: ['ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: 'system/roles',
|
||||
name: 'system-roles',
|
||||
component: () => import('../views/OperationLogView.vue'),
|
||||
meta: { roles: ['ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: 'settings/system',
|
||||
name: 'settings-system',
|
||||
component: () => import('../views/SystemSettingsView.vue'),
|
||||
meta: { roles: ['ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: 'settings/profile',
|
||||
name: 'settings-profile',
|
||||
component: () => import('../views/UserProfileView.vue'),
|
||||
meta: { roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] }
|
||||
},
|
||||
{
|
||||
path: 'settings/my-logs',
|
||||
name: 'settings-my-logs',
|
||||
component: () => import('../views/MyOperationLogView.vue'),
|
||||
meta: { roles: ['ADMIN', 'DECISION_MAKER', 'SUPERVISOR', 'GRID_WORKER', 'PUBLIC_SUPERVISOR'] }
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
name: 'about',
|
||||
component: () => import('../views/AboutView.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const publicPages = ['/login', '/register', '/forgot-password', '/reset-password', '/public-feedback'];
|
||||
const authRequired = !publicPages.includes(to.path);
|
||||
const authStore = useAuthStore();
|
||||
|
||||
if (authRequired && !authStore.isLoggedIn) {
|
||||
return next('/login');
|
||||
}
|
||||
|
||||
// 检查路由是否需要特定角色
|
||||
if (to.meta.roles) {
|
||||
const userRole = authStore.user?.role;
|
||||
if (userRole && (to.meta.roles as string[]).includes(userRole)) {
|
||||
next();
|
||||
} else {
|
||||
// 如果用户角色不被允许,可以重定向到 403 页面或主页
|
||||
// 这里我们简单地重定向到用户各自的主页
|
||||
if(userRole) {
|
||||
switch (userRole) {
|
||||
case 'GRID_WORKER':
|
||||
return next('/my-tasks');
|
||||
case 'PUBLIC_SUPERVISOR':
|
||||
return next('/submit-feedback');
|
||||
default:
|
||||
return next('/dashboard');
|
||||
}
|
||||
}
|
||||
return next('/login'); // 如果没有角色信息,则返回登录页
|
||||
}
|
||||
} else {
|
||||
// 如果路由没有 meta.roles,则允许所有已登录用户访问
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
export default router
|
||||
4
ems-frontend/src/shims-element-plus.d.ts
vendored
Normal file
4
ems-frontend/src/shims-element-plus.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'element-plus/dist/locale/zh-cn.mjs' {
|
||||
const locale: any;
|
||||
export default locale;
|
||||
}
|
||||
5
ems-frontend/src/shims-vue.d.ts
vendored
Normal file
5
ems-frontend/src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
123
ems-frontend/src/store/feedback.ts
Normal file
123
ems-frontend/src/store/feedback.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import {
|
||||
getFeedbackList,
|
||||
getFeedbackDetail,
|
||||
processFeedback,
|
||||
assignFeedback,
|
||||
resolveFeedback,
|
||||
closeFeedback,
|
||||
rejectFeedback,
|
||||
confirmFeedback,
|
||||
getFeedbackStats,
|
||||
submitFeedback,
|
||||
FeedbackStatus
|
||||
} from '@/api/feedback';
|
||||
import type {
|
||||
FeedbackResponse,
|
||||
FeedbackDetail,
|
||||
FeedbackFilters
|
||||
} from '@/api/feedback';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
interface FeedbackState {
|
||||
feedbackList: FeedbackResponse[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
currentFeedbackDetail: FeedbackDetail | null;
|
||||
detailLoading: boolean;
|
||||
stats: {
|
||||
total: number;
|
||||
pending: number;
|
||||
confirmed: number;
|
||||
assigned: number;
|
||||
inProgress: number;
|
||||
resolved: number;
|
||||
closed: number;
|
||||
rejected: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const useFeedbackStore = defineStore('feedback', {
|
||||
state: (): FeedbackState => ({
|
||||
feedbackList: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
currentFeedbackDetail: null,
|
||||
detailLoading: false,
|
||||
stats: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
confirmed: 0,
|
||||
assigned: 0,
|
||||
inProgress: 0,
|
||||
resolved: 0,
|
||||
closed: 0,
|
||||
rejected: 0
|
||||
}
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchFeedbackList(params: FeedbackFilters) {
|
||||
this.loading = true;
|
||||
const authStore = useAuthStore();
|
||||
const user = authStore.user;
|
||||
|
||||
let apiParams: FeedbackFilters = { ...params };
|
||||
|
||||
if (user && user.role === 'PUBLIC_SUPERVISOR') {
|
||||
apiParams.submitterId = user.id;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getFeedbackList(apiParams);
|
||||
this.feedbackList = response.content;
|
||||
this.total = response.totalElements;
|
||||
} catch (error) {
|
||||
console.error('获取反馈列表失败:', error);
|
||||
ElMessage.error('获取反馈列表失败,请检查网络或联系管理员');
|
||||
this.feedbackList = [];
|
||||
this.total = 0;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchFeedbackDetail(id: number) {
|
||||
this.detailLoading = true;
|
||||
try {
|
||||
this.currentFeedbackDetail = await getFeedbackDetail(id);
|
||||
} catch (error) {
|
||||
console.error('获取反馈详情失败:', error);
|
||||
ElMessage.error('无法加载反馈详情');
|
||||
this.currentFeedbackDetail = null;
|
||||
} finally {
|
||||
this.detailLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchFeedbackStats() {
|
||||
try {
|
||||
const statsData = await getFeedbackStats();
|
||||
this.stats = { ...this.stats, ...statsData };
|
||||
} catch (error) {
|
||||
console.error('获取反馈统计数据API错误:', error);
|
||||
ElMessage.error('获取反馈统计数据失败');
|
||||
}
|
||||
},
|
||||
|
||||
async submitFeedback(formData: FormData) {
|
||||
try {
|
||||
await submitFeedback(formData);
|
||||
this.fetchFeedbackStats();
|
||||
} catch (error) {
|
||||
console.error('提交反馈失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 可以在这里添加更多处理反馈的 actions, 例如:
|
||||
// async process(id: number, data: ProcessFeedbackRequest) { ... }
|
||||
// async assign(id: number, data: AssignFeedbackRequest) { ... }
|
||||
}
|
||||
});
|
||||
127
ems-frontend/src/stores/auth.ts
Normal file
127
ems-frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import type { LoginRequest, User } from '@/api/types';
|
||||
import apiClient from '@/api';
|
||||
import { loginWithFetch, logout as apiLogout } from '@/api/auth';
|
||||
import router from '@/router'; // 导入 Vue Router 实例
|
||||
|
||||
// 定义从JWT解码出的负载的类型, 它继承自 User 类型
|
||||
interface JwtPayload extends User {
|
||||
iat: number; // Issued At
|
||||
exp: number; // Expiration Time
|
||||
sub: string; // Subject, typically the user's email or ID
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('token'));
|
||||
const user = ref<User | null>(null);
|
||||
|
||||
/**
|
||||
* 从 token 解码并设置 user state
|
||||
* @param tokenValue - JWT token string
|
||||
*/
|
||||
function setUserFromToken(tokenValue: string) {
|
||||
try {
|
||||
const decoded = jwtDecode<JwtPayload>(tokenValue);
|
||||
// 解码出的信息直接赋值给 user state
|
||||
user.value = decoded;
|
||||
} catch (error) {
|
||||
console.error('Failed to decode token or token is invalid:', error);
|
||||
// 解码失败时, 清理状态
|
||||
user.value = null;
|
||||
token.value = null;
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时,如果localStorage中存在 token,则从中恢复用户信息
|
||||
if (token.value) {
|
||||
setUserFromToken(token.value);
|
||||
}
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value && !!user.value);
|
||||
|
||||
/**
|
||||
* 设置认证信息
|
||||
* @param newToken - JWT
|
||||
*/
|
||||
function setAuth(newToken: string) {
|
||||
token.value = newToken;
|
||||
localStorage.setItem('token', newToken);
|
||||
setUserFromToken(newToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param credentials - 登录凭据
|
||||
*/
|
||||
async function login(credentials: LoginRequest) {
|
||||
try {
|
||||
const response = await loginWithFetch(credentials);
|
||||
const newToken = response.accessToken;
|
||||
|
||||
if (!newToken) {
|
||||
throw new Error('accessToken not found in login response');
|
||||
}
|
||||
|
||||
setAuth(newToken);
|
||||
|
||||
// 登录成功后,根据用户角色进行重定向
|
||||
if (user.value) {
|
||||
switch (user.value.role) {
|
||||
case 'GRID_WORKER':
|
||||
router.push('/my-tasks');
|
||||
break;
|
||||
case 'ADMIN':
|
||||
case 'DECISION_MAKER':
|
||||
router.push('/dashboard');
|
||||
break;
|
||||
case 'SUPERVISOR':
|
||||
case 'PUBLIC_SUPERVISOR':
|
||||
router.push('/feedback');
|
||||
break;
|
||||
default:
|
||||
// 对于其他未知角色,可以重定向到登录页或一个通用的欢迎页
|
||||
router.push('/login');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 如果用户信息未设置,则默认重定向到主页
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Login process failed:', error);
|
||||
delete apiClient.defaults.headers.common['Authorization'];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
async function logout() {
|
||||
try {
|
||||
// 调用后端的登出接口以记录日志
|
||||
await apiLogout();
|
||||
} catch (error) {
|
||||
console.error('Failed to call logout API, but proceeding with client-side logout:', error);
|
||||
} finally {
|
||||
// 无论后端调用是否成功,都清理前端状态
|
||||
user.value = null;
|
||||
token.value = null;
|
||||
localStorage.removeItem('token');
|
||||
// 登出后重定向到登录页
|
||||
router.push('/login');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isLoggedIn,
|
||||
login,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
12
ems-frontend/src/stores/counter.ts
Normal file
12
ems-frontend/src/stores/counter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
20
ems-frontend/src/utils/formatter.ts
Normal file
20
ems-frontend/src/utils/formatter.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { format } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Formats a date-time string or Date object into a standardized "yyyy-MM-dd HH:mm:ss" format.
|
||||
* If the input is invalid or null, it returns an empty string.
|
||||
*
|
||||
* @param dateTime The date-time to format (string, Date, or null/undefined).
|
||||
* @returns The formatted date-time string or an empty string.
|
||||
*/
|
||||
export function formatDateTime(dateTime: string | Date | null | undefined): string {
|
||||
if (!dateTime) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return format(new Date(dateTime), 'yyyy-MM-dd HH:mm:ss');
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
12
ems-frontend/src/views/AboutView.vue
Normal file
12
ems-frontend/src/views/AboutView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>关于页面</h1>
|
||||
<p>这是一个使用 Vue 3 和 Element Plus 构建的环境监督系统。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
459
ems-frontend/src/views/FeedbackView.vue
Normal file
459
ems-frontend/src/views/FeedbackView.vue
Normal file
@@ -0,0 +1,459 @@
|
||||
<template>
|
||||
<div class="feedback-management-page">
|
||||
<!-- 统计面板 -->
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.total }}</div>
|
||||
<div class="stats-label">总反馈</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stats-card pending">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.pending }}</div>
|
||||
<div class="stats-label">待处理</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stats-card confirmed">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.confirmed }}</div>
|
||||
<div class="stats-label">已确认</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stats-card assigned">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.assigned }}</div>
|
||||
<div class="stats-label">已分配</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stats-card resolved">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.resolved }}</div>
|
||||
<div class="stats-label">已解决</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="stats-card rejected">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.rejected }}</div>
|
||||
<div class="stats-label">已拒绝</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="page-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>反馈管理</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<div class="filter-container">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="请选择状态" clearable>
|
||||
<el-option
|
||||
v-for="item in filterOptions.status"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="污染类型">
|
||||
<el-select v-model="filters.pollutionType" placeholder="请选择污染类型" clearable>
|
||||
<el-option
|
||||
v-for="item in filterOptions.pollutionType"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="严重程度">
|
||||
<el-select
|
||||
v-model="filters.severityLevel"
|
||||
placeholder="所有程度"
|
||||
clearable
|
||||
:popper-append-to-body="false"
|
||||
>
|
||||
<template #default>
|
||||
<el-option
|
||||
v-for="(text, key) in severityLevelMap"
|
||||
:key="key"
|
||||
:label="text"
|
||||
:value="key"
|
||||
/>
|
||||
</template>
|
||||
<template #prefix>{{ selectedSeverityLabel }}</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="提交时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filters.keyword" placeholder="标题、描述、报告人" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 反馈列表 -->
|
||||
<el-table :data="feedbackList" v-loading="loading" stripe class="feedback-table">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTagMap[scope.row.status] || 'info'">
|
||||
{{ statusMap[scope.row.status] || '未知状态' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pollutionType" label="污染类型" width="120">
|
||||
<template #default="{ row }: { row: FeedbackResponse }">
|
||||
<span>{{ pollutionTypeMap[row.pollutionType] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="severityLevel" label="严重程度" width="100">
|
||||
<template #default="{ row }: { row: FeedbackResponse }">
|
||||
<el-tag :type="severityTagMap[row.severityLevel]">{{ severityLevelMap[row.severityLevel] }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="网格坐标" width="120">
|
||||
<template #default="{ row }: { row: FeedbackResponse }">
|
||||
<span v-if="row.gridX !== null && row.gridY !== null">
|
||||
{{ `X: ${row.gridX}, Y: ${row.gridY}` }}
|
||||
</span>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处理人" width="120">
|
||||
<template #default="{ row }: { row: FeedbackResponse }">
|
||||
<span v-if="row.task && row.task.assignee">
|
||||
{{ row.task.assignee.name }}
|
||||
</span>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="user.name" label="报告人" width="120">
|
||||
<template #default="{ row }: { row: FeedbackResponse }">
|
||||
<div>
|
||||
<span>{{ row.user ? row.user.name : '未知' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="提交时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @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.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<FeedbackDetailDialog
|
||||
:visible="detailDialogVisible"
|
||||
:feedback-id="selectedFeedbackId"
|
||||
@close="detailDialogVisible = false"
|
||||
@refresh="fetchFeedbackList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue';
|
||||
import { useFeedbackStore } from '@/store/feedback';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { FeedbackStatus, SeverityLevel } from '@/api/feedback';
|
||||
import { PollutionType } from '@/api/types';
|
||||
import type { FeedbackResponse, FeedbackFilters } from '@/api/feedback';
|
||||
import FeedbackDetailDialog from '@/components/FeedbackDetailDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const feedbackStore = useFeedbackStore();
|
||||
const { feedbackList, total, loading, stats } = storeToRefs(feedbackStore);
|
||||
|
||||
// 使用 reactive 创建过滤条件对象
|
||||
const filters = reactive<Omit<FeedbackFilters, 'page' | 'size'>>({
|
||||
status: undefined,
|
||||
pollutionType: undefined,
|
||||
severityLevel: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
keyword: undefined,
|
||||
});
|
||||
|
||||
// 用于筛选器的状态子集
|
||||
const filterableStatusMap: Partial<Record<FeedbackStatus, string>> = {
|
||||
[FeedbackStatus.AI_REVIEWING]: 'AI审核中',
|
||||
[FeedbackStatus.IN_PROGRESS]: '处理中',
|
||||
[FeedbackStatus.PENDING_ASSIGNMENT]: '待分配',
|
||||
[FeedbackStatus.PROCESSED]: '已处理',
|
||||
};
|
||||
|
||||
// 使用计算属性来确保状态的显示名称
|
||||
const selectedStatusLabel = computed(() => {
|
||||
return filters.status ? statusMap[filters.status] : '全部状态';
|
||||
});
|
||||
|
||||
const selectedPollutionTypeLabel = computed(() => {
|
||||
return filters.pollutionType ? pollutionTypeMap[filters.pollutionType] : '所有类型';
|
||||
});
|
||||
|
||||
const selectedSeverityLabel = computed(() => {
|
||||
return filters.severityLevel ? severityLevelMap[filters.severityLevel] : '所有程度';
|
||||
});
|
||||
|
||||
// 日期范围
|
||||
const dateRange = ref<[string, string] | null>(null);
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
});
|
||||
|
||||
const detailDialogVisible = ref(false);
|
||||
const selectedFeedbackId = ref<number | null>(null);
|
||||
|
||||
// 映射关系
|
||||
const statusMap: Record<string, string> = {
|
||||
'AI_REVIEWING': 'AI审核中',
|
||||
'AI_REVIEW_FAILED': 'AI审核失败',
|
||||
'PENDING_REVIEW': '待人工审核',
|
||||
'PENDING_ASSIGNMENT': '待分配',
|
||||
'ASSIGNED': '已分配',
|
||||
'CONFIRMED': '已确认',
|
||||
'CLOSED_INVALID': '无效关闭',
|
||||
'PROCESSED': '已处理',
|
||||
'IN_PROGRESS': '处理中',
|
||||
'RESOLVED': '已处理',
|
||||
'COMPLETED': '已处理'
|
||||
};
|
||||
const statusTagMap: Record<string, 'info' | 'warning' | 'danger' | 'success'> = {
|
||||
'AI_REVIEWING': 'info',
|
||||
'AI_REVIEW_FAILED': 'danger',
|
||||
'PENDING_REVIEW': 'warning',
|
||||
'PENDING_ASSIGNMENT': 'warning',
|
||||
'ASSIGNED': 'warning',
|
||||
'CONFIRMED': 'success',
|
||||
'CLOSED_INVALID': 'info',
|
||||
'PROCESSED': 'success',
|
||||
'IN_PROGRESS': 'warning',
|
||||
'RESOLVED': 'success',
|
||||
'COMPLETED': 'success'
|
||||
};
|
||||
const pollutionTypeMap: Record<PollutionType, string> = {
|
||||
|
||||
[PollutionType.PM25]: 'PM2.5',
|
||||
[PollutionType.O3]: '臭氧',
|
||||
[PollutionType.SO2]: '二氧化硫',
|
||||
[PollutionType.NO2]: '二氧化氮',
|
||||
[PollutionType.OTHER]: '其他'
|
||||
};
|
||||
const severityLevelMap: Record<SeverityLevel, string> = {
|
||||
[SeverityLevel.LOW]: '低',
|
||||
[SeverityLevel.MEDIUM]: '中',
|
||||
[SeverityLevel.HIGH]: '高',
|
||||
};
|
||||
const severityTagMap: Record<SeverityLevel, 'info' | 'warning' | 'danger'> = {
|
||||
[SeverityLevel.LOW]: 'info',
|
||||
[SeverityLevel.MEDIUM]: 'warning',
|
||||
[SeverityLevel.HIGH]: 'danger',
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return 'N/A';
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
const fetchFeedbackList = () => {
|
||||
const params = {
|
||||
...filters,
|
||||
page: pagination.page - 1, // 后端分页从0开始
|
||||
size: pagination.size,
|
||||
};
|
||||
feedbackStore.fetchFeedbackList(params);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1;
|
||||
fetchFeedbackList();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(filters, {
|
||||
status: undefined,
|
||||
pollutionType: undefined,
|
||||
severityLevel: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
keyword: undefined,
|
||||
});
|
||||
dateRange.value = null;
|
||||
fetchFeedbackList();
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.size = size;
|
||||
fetchFeedbackList();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page;
|
||||
fetchFeedbackList();
|
||||
};
|
||||
|
||||
const handleViewDetails = (row: FeedbackResponse) => {
|
||||
selectedFeedbackId.value = row.id;
|
||||
detailDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const fetchFeedbackStats = () => {
|
||||
feedbackStore.fetchFeedbackStats();
|
||||
};
|
||||
|
||||
const filterOptions = {
|
||||
status: [
|
||||
{ value: 'PENDING_REVIEW', label: '待人工审核' },
|
||||
{ value: 'PENDING_ASSIGNMENT', label: '待分配' },
|
||||
{ value: 'ASSIGNED', label: '已分配' },
|
||||
{ value: 'IN_PROGRESS', label: '处理中' },
|
||||
{ value: 'PROCESSED', label: '已处理' },
|
||||
{ value: 'CLOSED_INVALID', label: '无效关闭' }
|
||||
],
|
||||
pollutionType: Object.entries(pollutionTypeMap).map(([value, label]) => ({ value, label })),
|
||||
// ... more filter options
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchFeedbackList();
|
||||
fetchFeedbackStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feedback-management-page {
|
||||
padding: 20px;
|
||||
}
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stats-card {
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stats-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stats-item {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
.stats-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.stats-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.pending .stats-value {
|
||||
color: #e6a23c;
|
||||
}
|
||||
.confirmed .stats-value {
|
||||
color: #409eff;
|
||||
}
|
||||
.assigned .stats-value {
|
||||
color: #67c23a;
|
||||
}
|
||||
.resolved .stats-value {
|
||||
color: #67c23a;
|
||||
}
|
||||
.rejected .stats-value {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.page-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filter-form .el-form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.feedback-table {
|
||||
width: 100%;
|
||||
}
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 选中的过滤器样式 */
|
||||
:deep(.el-select) .el-input__prefix {
|
||||
font-weight: bold;
|
||||
color: #409eff !important;
|
||||
}
|
||||
:deep(.el-input--prefix .el-input__inner) {
|
||||
color: #409eff;
|
||||
font-weight: bold;
|
||||
}
|
||||
:deep(.el-date-editor.is-active) {
|
||||
color: #409eff;
|
||||
font-weight: bold;
|
||||
}
|
||||
:deep(.el-input.is-active .el-input__inner) {
|
||||
color: #409eff;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
187
ems-frontend/src/views/ForgotPasswordView.vue
Normal file
187
ems-frontend/src/views/ForgotPasswordView.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="forgot-password-container">
|
||||
<el-card class="forgot-password-card">
|
||||
<div class="card-header">
|
||||
<h2>找回密码</h2>
|
||||
<p>请输入您的注册邮箱以接收密码重置邮件</p>
|
||||
</div>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
hide-required-asterisk
|
||||
@submit.prevent="handleSubmit"
|
||||
autocomplete="off"
|
||||
>
|
||||
<el-form-item label="邮箱地址" prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
placeholder="请输入您注册时使用的邮箱"
|
||||
size="large"
|
||||
autocomplete="off"
|
||||
>
|
||||
<template #append>
|
||||
<el-button
|
||||
:disabled="isSending"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
<div class="card-footer">
|
||||
<el-link @click="$router.push('/login')">返回登录</el-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { sendPasswordResetCode } from '@/api/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const formRef = ref<FormInstance>();
|
||||
const isSending = ref(false);
|
||||
const countdown = ref(0);
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] }
|
||||
],
|
||||
});
|
||||
|
||||
const buttonText = computed(() => {
|
||||
if (isSending.value) {
|
||||
return `${countdown.value}s 后可重发`;
|
||||
}
|
||||
return '发送重置邮件';
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return;
|
||||
formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
isSending.value = true;
|
||||
countdown.value = 60;
|
||||
try {
|
||||
await sendPasswordResetCode(form.email);
|
||||
ElMessage.success('密码重置邮件已发送,即将跳转...');
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (countdown.value > 0) {
|
||||
countdown.value--;
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
isSending.value = false;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
router.push({ path: '/reset-password', query: { email: form.email } });
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
ElMessage.error('邮件发送失败,请检查邮箱地址是否正确。');
|
||||
isSending.value = false;
|
||||
countdown.value = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.forgot-password-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background-image: url('/forgot-password-bg.avif');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.forgot-password-card {
|
||||
width: 480px;
|
||||
border-radius: 25px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
color: #004d40;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
color: #607d8b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
background-color: #004d40;
|
||||
border-color: #004d40;
|
||||
transition: background-color 0.3s ease;
|
||||
font-size: 1rem;
|
||||
padding: 1.2rem 0;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background-color: #00695c;
|
||||
border-color: #00695c;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-top-left-radius: 15px;
|
||||
border-bottom-left-radius: 15px;
|
||||
}
|
||||
|
||||
:deep(.el-input-group__append) {
|
||||
background-color: #004d40;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-top-right-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
}
|
||||
|
||||
:deep(.el-input-group__append:hover) {
|
||||
background-color: #00695c;
|
||||
}
|
||||
|
||||
:deep(.el-input-group__append .el-button) {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
731
ems-frontend/src/views/GridManagementView.vue
Normal file
731
ems-frontend/src/views/GridManagementView.vue
Normal file
@@ -0,0 +1,731 @@
|
||||
<template>
|
||||
<div class="grid-management-container">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h2>网格地图</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="refreshData" :loading="loading || isLoadingWorkers" icon="Refresh">
|
||||
刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content-grid">
|
||||
<!-- Grid Map Area -->
|
||||
<div class="map-area" v-loading="loading">
|
||||
<div v-if="error" class="error-message">
|
||||
<el-alert title="地图数据加载失败" :description="error" type="error" show-icon :closable="false" />
|
||||
<el-button @click="fetchAllGrids" type="primary" class="retry-button">重试</el-button>
|
||||
</div>
|
||||
<svg v-else-if="displayGrids.length" :width="svgDimensions.width" :height="svgDimensions.height" class="grid-svg">
|
||||
<defs>
|
||||
<pattern id="grid-pattern" :width="CELL_SIZE" :height="CELL_SIZE" patternUnits="userSpaceOnUse">
|
||||
<path :d="`M ${CELL_SIZE} 0 L 0 0 0 ${CELL_SIZE}`" fill="none" stroke="#e9e9e9" stroke-width="0.5" />
|
||||
</pattern>
|
||||
|
||||
<!-- Softer Gradient for the path -->
|
||||
<linearGradient id="soft-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#89f7fe; stop-opacity:1" /> <!-- Light Sky Blue -->
|
||||
<stop offset="100%" style="stop-color:#66a6ff; stop-opacity:1" /> <!-- Soft Lavender Blue -->
|
||||
</linearGradient>
|
||||
|
||||
</defs>
|
||||
<rect :width="svgDimensions.width" :height="svgDimensions.height" fill="url(#grid-pattern)" />
|
||||
|
||||
<g v-for="grid in displayGrids" :key="grid.id">
|
||||
<!-- Replace rect with foreignObject to allow for CSS pseudo-elements -->
|
||||
<foreignObject
|
||||
:x="grid.displayX * CELL_SIZE"
|
||||
:y="grid.displayY * CELL_SIZE"
|
||||
:width="CELL_SIZE"
|
||||
:height="CELL_SIZE"
|
||||
>
|
||||
<div
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
class="grid-cell-wrapper"
|
||||
@click="selectGrid(grid)"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'grid-cell': true,
|
||||
'selected': selectedGrid && selectedGrid.id === grid.id,
|
||||
'is-path': pathInfo.pathSet.has(`${grid.gridX},${grid.gridY}`),
|
||||
'is-start': pathInfo.start === `${grid.gridX},${grid.gridY}`,
|
||||
'is-end': pathInfo.end === `${grid.gridX},${grid.gridY}`
|
||||
}"
|
||||
:style="{
|
||||
'animation-delay': `${(pathInfo.pathMap.get(`${grid.gridX},${grid.gridY}`) || 0) * 50}ms`,
|
||||
'background-color': getGridFillColor(grid)
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
|
||||
<!-- Path Number Text remains as SVG text -->
|
||||
<text
|
||||
v-if="pathInfo.pathSet.has(`${grid.gridX},${grid.gridY}`)"
|
||||
:x="grid.displayX * CELL_SIZE + CELL_SIZE / 2"
|
||||
:y="grid.displayY * CELL_SIZE + CELL_SIZE / 2"
|
||||
class="path-text"
|
||||
:style="{ 'animation-delay': `${(pathInfo.pathMap.get(`${grid.gridX},${grid.gridY}`) || 0) * 50}ms` }"
|
||||
>
|
||||
{{ (pathInfo.pathMap.get(`${grid.gridX},${grid.gridY}`) || 0) + 1 }}
|
||||
</text>
|
||||
<!-- Border Rendering -->
|
||||
<line v-if="getBorder(grid, 'top')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="grid.displayY * CELL_SIZE" class="border-line" />
|
||||
<line v-if="getBorder(grid, 'right')" :x1="(grid.displayX + 1) * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
|
||||
<line v-if="getBorder(grid, 'bottom')" :x1="grid.displayX * CELL_SIZE" :y1="(grid.displayY + 1) * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
|
||||
<line v-if="getBorder(grid, 'left')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="grid.displayX * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
|
||||
</g>
|
||||
</svg>
|
||||
<el-empty v-else description="没有可显示的网格数据" />
|
||||
</div>
|
||||
|
||||
<!-- Details Sidebar -->
|
||||
<div class="sidebar-area">
|
||||
<el-card shadow="never" class="details-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>网格详情</span>
|
||||
<el-button v-if="selectedGrid && !isEditing && canEdit" type="primary" link @click="enterEditMode" icon="Edit">
|
||||
编辑
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isSaving" v-loading="true" class="details-content-loading"></div>
|
||||
|
||||
<div v-else-if="selectedGrid" class="details-content">
|
||||
<!-- Display Mode -->
|
||||
<template v-if="!isEditing">
|
||||
<p><strong>网格ID:</strong> {{ selectedGrid.id }}</p>
|
||||
<p><strong>城市:</strong> <el-tag :color="getCityColor(selectedGrid.cityName)" effect="light">{{ selectedGrid.cityName }}</el-tag></p>
|
||||
<p><strong>区县:</strong> {{ selectedGrid.districtName || 'N/A' }}</p>
|
||||
<p><strong>原始坐标:</strong> ({{ selectedGrid.gridX }}, {{ selectedGrid.gridY }})</p>
|
||||
<p><strong>是否为障碍物:</strong> <el-tag :type="selectedGrid.isObstacle ? 'danger' : 'success'">{{ selectedGrid.isObstacle ? '是' : '否' }}</el-tag></p>
|
||||
<p><strong>描述:</strong> {{ selectedGrid.description || '无' }}</p>
|
||||
<el-divider />
|
||||
<p><strong>负责人:</strong></p>
|
||||
<div v-if="selectedGridWorker">
|
||||
<el-tag effect="plain" type="info">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ selectedGridWorker.name }} ({{ selectedGridWorker.phone }})
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-tag type="warning">未分配</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Editing Mode -->
|
||||
<template v-else-if="canEdit">
|
||||
<el-form label-position="top" label-width="100px">
|
||||
<el-form-item label="是否为障碍物">
|
||||
<el-switch v-model="editableIsObstacle" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input type="textarea" v-model="editableDescription" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分配网格员">
|
||||
<el-select
|
||||
v-model="selectedWorkerIdToAssign"
|
||||
placeholder="请选择网格员"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-option label="-- 未分配 --" value="unassigned"></el-option>
|
||||
<el-option
|
||||
v-for="worker in allWorkers"
|
||||
:key="worker.id"
|
||||
:label="`${worker.name} (${worker.phone})`"
|
||||
:value="worker.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="edit-actions">
|
||||
<el-button @click="cancelEdit">取消</el-button>
|
||||
<el-button type="primary" @click="saveChanges" :loading="isSaving">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<el-empty v-else description="请在左侧地图上选择一个网格" />
|
||||
</el-card>
|
||||
|
||||
<!-- Pathfinding Card -->
|
||||
<el-card shadow="never" class="details-card pathfinding-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>路径规划</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!authStore.user" class="pathfinding-content">
|
||||
<el-empty description="请先登录以使用路径规划功能" />
|
||||
</div>
|
||||
<div v-else class="pathfinding-content">
|
||||
<el-form label-position="top" :model="pathfinding" @submit.prevent="handleFindPath">
|
||||
<el-form-item label="起点坐标 (X, Y)">
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<el-input-number v-model="pathfinding.startX" :controls="false" placeholder="X" style="width: 100%" />
|
||||
<el-input-number v-model="pathfinding.startY" :controls="false" placeholder="Y" style="width: 100%" />
|
||||
</div>
|
||||
<small class="form-help-text">默认为您分配的网格,或 (0,0)</small>
|
||||
</el-form-item>
|
||||
<el-form-item label="终点坐标 (X, Y)">
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<el-input-number v-model="pathfinding.endX" :controls="false" placeholder="X" style="width: 100%" />
|
||||
<el-input-number v-model="pathfinding.endY" :controls="false" placeholder="Y" style="width: 100%" />
|
||||
</div>
|
||||
<small class="form-help-text">点击地图上的网格可快速设置终点</small>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="pathfinding-actions">
|
||||
<el-button @click="clearPath" :disabled="!calculatedPath.length">清除路径</el-button>
|
||||
<el-button type="primary" @click="handleFindPath" :loading="isPathfindingLoading">规划路径</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { Grid, UserAccount } from '@/api/types';
|
||||
import { getGrids } from '@/api/dashboard';
|
||||
import { getAllGridWorkers } from '@/api/personnel';
|
||||
import { updateGrid, assignWorkerByCoordinates, unassignWorkerByCoordinates } from '@/api/grid';
|
||||
import { findPath, type Point, type PathfindingRequest } from '@/api/pathfinding';
|
||||
import { User } from '@element-plus/icons-vue';
|
||||
|
||||
const CELL_SIZE = 25;
|
||||
const PADDING_CELLS = 2;
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// --- State ---
|
||||
const gridData = ref<Grid[]>([]);
|
||||
const allWorkers = ref<UserAccount[]>([]);
|
||||
const loading = ref(false); // For main grid loading
|
||||
const isLoadingWorkers = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const selectedGrid = ref<DisplayGrid | null>(null);
|
||||
const isEditing = ref(false);
|
||||
|
||||
// --- Editing State ---
|
||||
const editableDescription = ref('');
|
||||
const editableIsObstacle = ref(false);
|
||||
const selectedWorkerIdToAssign = ref<number | 'unassigned' | null>(null);
|
||||
|
||||
// --- Pathfinding State ---
|
||||
const pathfinding = ref({
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
endX: undefined as number | undefined,
|
||||
endY: undefined as number | undefined
|
||||
});
|
||||
const calculatedPath = ref<Point[]>([]);
|
||||
const isPathfindingLoading = ref(false);
|
||||
|
||||
// --- Data Fetching & Refresh ---
|
||||
const fetchAllGrids = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
selectedGrid.value = null; // Reset selection
|
||||
try {
|
||||
const response = await getGrids();
|
||||
gridData.value = Array.isArray(response) ? response : (response as any).content || [];
|
||||
} catch (e: any) {
|
||||
const errorMessage = e.message || '获取网格数据时发生未知错误';
|
||||
error.value = errorMessage;
|
||||
ElMessage.error(errorMessage);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllWorkers = async () => {
|
||||
const userRole = authStore.user?.role;
|
||||
// 只有管理员和主管需要获取完整的网格员列表用于管理
|
||||
if (userRole !== 'ADMIN' && userRole !== 'SUPERVISOR') {
|
||||
allWorkers.value = []; // 其他角色无需加载网格员数据
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingWorkers.value = true;
|
||||
try {
|
||||
allWorkers.value = await getAllGridWorkers();
|
||||
} catch (e: any) {
|
||||
ElMessage.error('获取网格员列表失败: ' + e.message);
|
||||
} finally {
|
||||
isLoadingWorkers.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([fetchAllGrids(), fetchAllWorkers()]);
|
||||
ElMessage.success('数据已刷新');
|
||||
// Set default start point for pathfinding if user has one
|
||||
const user = authStore.user;
|
||||
if (user && typeof user.gridX === 'number' && typeof user.gridY === 'number') {
|
||||
pathfinding.value.startX = user.gridX;
|
||||
pathfinding.value.startY = user.gridY;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
});
|
||||
|
||||
// --- Computed Properties ---
|
||||
const canEdit = computed(() => {
|
||||
const role = authStore.user?.role;
|
||||
return role === 'ADMIN' || role === 'SUPERVISOR';
|
||||
});
|
||||
|
||||
const workersByCoord = computed<Map<string, UserAccount>>(() => {
|
||||
const map = new Map<string, UserAccount>();
|
||||
allWorkers.value.forEach(worker => {
|
||||
if (worker.gridX !== null && worker.gridY !== null) {
|
||||
map.set(`${worker.gridX},${worker.gridY}`, worker);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const selectedGridWorker = computed<UserAccount | undefined>(() => {
|
||||
if (!selectedGrid.value || !allWorkers.value.length) {
|
||||
return undefined;
|
||||
}
|
||||
return allWorkers.value.find(
|
||||
worker => worker.gridX === selectedGrid.value?.gridX && worker.gridY === selectedGrid.value?.gridY
|
||||
);
|
||||
});
|
||||
|
||||
const pathInfo = computed(() => {
|
||||
const pathSet = new Set<string>();
|
||||
const pathMap = new Map<string, number>(); // Stores coordinate -> index for animation delay
|
||||
let start = '';
|
||||
let end = '';
|
||||
|
||||
if (calculatedPath.value.length > 0) {
|
||||
calculatedPath.value.forEach((point, index) => {
|
||||
const coord = `${point.x},${point.y}`;
|
||||
pathSet.add(coord);
|
||||
pathMap.set(coord, index);
|
||||
});
|
||||
const startPoint = calculatedPath.value[0];
|
||||
const endPoint = calculatedPath.value[calculatedPath.value.length - 1];
|
||||
start = `${startPoint.x},${startPoint.y}`;
|
||||
end = `${endPoint.x},${endPoint.y}`;
|
||||
}
|
||||
|
||||
return { pathSet, pathMap, start, end };
|
||||
});
|
||||
|
||||
// --- City Color Logic ---
|
||||
const cityColors = ref<Map<string, string>>(new Map());
|
||||
const getCityColor = (cityName: string): string => {
|
||||
if (!cityColors.value.has(cityName)) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < cityName.length; i++) {
|
||||
hash = cityName.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const color = `hsl(${hash % 360}, 80%, 85%)`;
|
||||
cityColors.value.set(cityName, color);
|
||||
}
|
||||
return cityColors.value.get(cityName)!;
|
||||
};
|
||||
|
||||
const getGridFillColor = (grid: Grid): string => {
|
||||
const isPath = pathInfo.value.pathSet.has(`${grid.gridX},${grid.gridY}`);
|
||||
// If it's part of the path, let the CSS class handle the gradient background
|
||||
if (isPath) {
|
||||
return 'transparent';
|
||||
}
|
||||
if (grid.isObstacle) {
|
||||
return '#000000'; // Black for obstacles
|
||||
}
|
||||
if (workersByCoord.value.has(`${grid.gridX},${grid.gridY}`)) {
|
||||
return '#FF0000'; // Red for grids with workers
|
||||
}
|
||||
return getCityColor(grid.cityName);
|
||||
};
|
||||
|
||||
// --- Display Logic ---
|
||||
interface DisplayGrid extends Grid {
|
||||
displayX: number;
|
||||
displayY: number;
|
||||
}
|
||||
|
||||
const displayGrids = computed<DisplayGrid[]>(() => {
|
||||
if (!gridData.value.length) return [];
|
||||
|
||||
// Determine the top-left corner of the entire map to normalize coordinates
|
||||
const minX = Math.min(...gridData.value.map(g => g.gridX));
|
||||
const minY = Math.min(...gridData.value.map(g => g.gridY));
|
||||
|
||||
// Render all grids based on their absolute positions, normalized to start at (0,0)
|
||||
return gridData.value.map(grid => ({
|
||||
...grid,
|
||||
displayX: grid.gridX - minX,
|
||||
displayY: grid.gridY - minY,
|
||||
}));
|
||||
});
|
||||
|
||||
const gridMap = computed(() => {
|
||||
const map = new Map<string, DisplayGrid>();
|
||||
displayGrids.value.forEach(grid => {
|
||||
map.set(`${grid.displayX},${grid.displayY}`, grid);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const svgDimensions = computed(() => {
|
||||
if (!displayGrids.value.length) return { width: 0, height: 0 };
|
||||
const maxX = Math.max(...displayGrids.value.map(g => g.displayX));
|
||||
const maxY = Math.max(...displayGrids.value.map(g => g.displayY));
|
||||
return {
|
||||
width: (maxX + 1) * CELL_SIZE,
|
||||
height: (maxY + 1) * CELL_SIZE,
|
||||
};
|
||||
});
|
||||
|
||||
const getBorder = (grid: DisplayGrid, side: 'top' | 'right' | 'bottom' | 'left'): boolean => {
|
||||
const { displayX, displayY, cityName } = grid;
|
||||
let neighbor: DisplayGrid | undefined;
|
||||
switch (side) {
|
||||
case 'top':
|
||||
neighbor = gridMap.value.get(`${displayX},${displayY - 1}`);
|
||||
break;
|
||||
case 'right':
|
||||
neighbor = gridMap.value.get(`${displayX + 1},${displayY}`);
|
||||
break;
|
||||
case 'bottom':
|
||||
neighbor = gridMap.value.get(`${displayX},${displayY + 1}`);
|
||||
break;
|
||||
case 'left':
|
||||
neighbor = gridMap.value.get(`${displayX - 1},${displayY}`);
|
||||
break;
|
||||
}
|
||||
return !neighbor || neighbor.cityName !== cityName;
|
||||
};
|
||||
|
||||
const selectGrid = (grid: DisplayGrid) => {
|
||||
selectedGrid.value = grid;
|
||||
|
||||
// Also set pathfinding endpoint for convenience
|
||||
pathfinding.value.endX = grid.gridX;
|
||||
pathfinding.value.endY = grid.gridY;
|
||||
|
||||
// If in edit mode, populate fields
|
||||
if (isEditing.value) {
|
||||
// populateEditFields(grid); // This function was removed, keep it commented or remove it if not needed
|
||||
}
|
||||
};
|
||||
|
||||
// --- Editing Logic ---
|
||||
const enterEditMode = () => {
|
||||
if (!selectedGrid.value) return;
|
||||
isEditing.value = true;
|
||||
editableDescription.value = selectedGrid.value.description || '';
|
||||
editableIsObstacle.value = selectedGrid.value.isObstacle;
|
||||
selectedWorkerIdToAssign.value = selectedGridWorker.value?.id ?? 'unassigned';
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
const saveChanges = async () => {
|
||||
if (!selectedGrid.value) return;
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
const { id, gridX, gridY } = selectedGrid.value;
|
||||
let needsGridRefresh = false;
|
||||
let needsWorkerRefresh = false;
|
||||
|
||||
// 1. Update grid details (obstacle, description)
|
||||
const originalDescription = selectedGrid.value.description || '';
|
||||
if (editableIsObstacle.value !== selectedGrid.value.isObstacle || editableDescription.value !== originalDescription) {
|
||||
await updateGrid(id, {
|
||||
isObstacle: editableIsObstacle.value,
|
||||
description: editableDescription.value,
|
||||
});
|
||||
needsGridRefresh = true;
|
||||
}
|
||||
|
||||
// 2. Update worker assignment
|
||||
const originalWorkerId = selectedGridWorker.value?.id;
|
||||
const newWorkerId = selectedWorkerIdToAssign.value;
|
||||
|
||||
if (newWorkerId !== (originalWorkerId ?? 'unassigned')) {
|
||||
if (newWorkerId === 'unassigned') {
|
||||
await unassignWorkerByCoordinates(gridX, gridY);
|
||||
} else if (newWorkerId) {
|
||||
await assignWorkerByCoordinates(gridX, gridY, newWorkerId);
|
||||
}
|
||||
needsWorkerRefresh = true;
|
||||
}
|
||||
|
||||
ElMessage.success('网格信息更新成功!');
|
||||
|
||||
// Refresh data
|
||||
if (needsGridRefresh) await fetchAllGrids();
|
||||
if (needsWorkerRefresh) await fetchAllWorkers();
|
||||
|
||||
isEditing.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e.response?.data?.message || e.message));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Pathfinding Methods ---
|
||||
const handleFindPath = async () => {
|
||||
if (typeof pathfinding.value.endX !== 'number' || typeof pathfinding.value.endY !== 'number') {
|
||||
ElMessage.warning('请输入有效的终点坐标。');
|
||||
return;
|
||||
}
|
||||
|
||||
isPathfindingLoading.value = true;
|
||||
calculatedPath.value = []; // Clear previous path
|
||||
|
||||
const request: PathfindingRequest = {
|
||||
startX: pathfinding.value.startX,
|
||||
startY: pathfinding.value.startY,
|
||||
endX: pathfinding.value.endX,
|
||||
endY: pathfinding.value.endY,
|
||||
};
|
||||
|
||||
try {
|
||||
const path = await findPath(request);
|
||||
if (path && path.length > 0) {
|
||||
calculatedPath.value = path;
|
||||
ElMessage.success(`路径规划成功,共 ${path.length} 步。`);
|
||||
} else {
|
||||
ElMessage.error('未找到有效路径,请检查起终点或地图障碍物。');
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error('路径规划失败: ' + (e.response?.data?.message || e.message));
|
||||
} finally {
|
||||
isPathfindingLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPath = () => {
|
||||
calculatedPath.value = [];
|
||||
ElMessage.info('路径已清除。');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-management-container {
|
||||
padding: 20px;
|
||||
background-color: #f7f8fa;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.main-content-grid {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-area {
|
||||
flex: 3;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e9e9e9;
|
||||
}
|
||||
|
||||
.sidebar-area {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.details-card .card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grid-svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid-cell-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: transparent; /* Default state */
|
||||
border: 0.5px solid #dcdfe6;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden; /* Important for pseudo-element animation */
|
||||
}
|
||||
|
||||
.grid-cell:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.grid-cell.selected {
|
||||
border: 2px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.grid-cell.is-start {
|
||||
background-color: #00e676;
|
||||
box-shadow: 0 0 8px #00e676, inset 0 0 5px rgba(255,255,255,0.7);
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.grid-cell.is-end {
|
||||
background-color: #ff5252;
|
||||
box-shadow: 0 0 8px #ff5252, inset 0 0 5px rgba(255,255,255,0.7);
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.grid-cell.is-path {
|
||||
background-image: linear-gradient(to bottom right, #89f7fe, #66a6ff);
|
||||
border-color: #66a6ff;
|
||||
opacity: 0;
|
||||
animation: draw-path-reveal 0.4s forwards ease-out;
|
||||
}
|
||||
|
||||
.grid-cell.is-path::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -150%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0) 100%);
|
||||
transform: skewX(-25deg);
|
||||
animation: light-sweep 1.2s ease-in-out 3 forwards; /* Play 3 times and stop */
|
||||
animation-delay: inherit; /* Inherit delay from parent */
|
||||
}
|
||||
|
||||
@keyframes light-sweep {
|
||||
100% {
|
||||
left: 150%;
|
||||
}
|
||||
}
|
||||
|
||||
.path-text {
|
||||
font-size: 10px;
|
||||
fill: white;
|
||||
font-weight: bold;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
animation: draw-path-reveal 0.4s forwards ease-out;
|
||||
}
|
||||
|
||||
.border-line {
|
||||
stroke: #000;
|
||||
stroke-width: 2.5px;
|
||||
margin-top: 15px;
|
||||
width: 100%; /* Ensure it takes full width */
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.details-content p {
|
||||
margin: 0 0 12px;
|
||||
color: #606266;
|
||||
margin-top: 4px;
|
||||
line-height: 1.2;
|
||||
width: 100%; /* Ensure it takes full width */
|
||||
}
|
||||
|
||||
.details-content p strong {
|
||||
color: #303133;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.details-content-loading {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pathfinding-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pathfinding-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.form-help-text {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
line-height: 1.2;
|
||||
width: 100%; /* Ensure it takes full width */
|
||||
}
|
||||
|
||||
@keyframes draw-path-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
filter: brightness(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(1.7);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1791
ems-frontend/src/views/HomeView.vue
Normal file
1791
ems-frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,1791 @@
|
||||
<template>
|
||||
<div v-if="canViewDashboard" class="dashboard-container">
|
||||
<div class="dashboard-header">
|
||||
<h1>环境监测系统仪表盘</h1>
|
||||
<div class="dashboard-actions">
|
||||
<el-button type="primary" :icon="Refresh" @click="refreshAllData">刷新全部</el-button>
|
||||
<el-button type="success" :icon="Setting">设置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<!-- Top Row: Key Stats -->
|
||||
<el-card shadow="hover" class="grid-item kpi-card" v-for="kpi in kpiStats" :key="kpi.title">
|
||||
<div class="kpi-content">
|
||||
<div class="kpi-icon" :style="{ backgroundColor: kpi.color }">
|
||||
<component :is="kpi.icon" />
|
||||
</div>
|
||||
<div class="kpi-details">
|
||||
<div class="kpi-title">{{ kpi.title }}</div>
|
||||
<div class="kpi-value">
|
||||
<span v-if="!loading.stats">{{ kpi.value }}</span>
|
||||
<span v-else class="loading-text">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- AQI Distribution Chart -->
|
||||
<el-card shadow="never" class="grid-item chart-card aqi-distribution" :body-style="{ padding: '16px', height: 'calc(100% - 53px)' }">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataAnalysis /></el-icon>
|
||||
<span>AQI 级别分布</span>
|
||||
<div class="card-actions">
|
||||
<el-tooltip content="查看详细数据" placement="top">
|
||||
<el-button type="success" :icon="View" circle size="small" @click="showAqiDetailData" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="刷新数据" placement="top">
|
||||
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshAqiData" :loading="loading.aqi" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="aqiDistributionChartRef" class="chart" v-if="!loading.aqi" />
|
||||
<el-skeleton :rows="5" animated v-else />
|
||||
</el-card>
|
||||
|
||||
<!-- Task Status Chart -->
|
||||
<el-card shadow="never" class="grid-item chart-card task-status" :body-style="{ padding: '16px', height: 'calc(100% - 53px)' }">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Finished /></el-icon>
|
||||
<span>任务状态分布</span>
|
||||
<div class="card-actions">
|
||||
<el-tooltip content="查看详细数据" placement="top">
|
||||
<el-button type="success" :icon="View" circle size="small" @click="showTaskDetailData" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="刷新数据" placement="top">
|
||||
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshTaskData" :loading="loading.tasks" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="taskStatusChartRef" class="chart" v-if="!loading.tasks" />
|
||||
<el-skeleton :rows="5" animated v-else />
|
||||
</el-card>
|
||||
|
||||
<!-- Exceedance Trend Chart -->
|
||||
<el-card shadow="never" class="grid-item chart-card exceedance-trend" :body-style="{ padding: '16px', height: 'calc(100% - 53px)' }">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><TrendCharts /></el-icon>
|
||||
<span>月度超标趋势</span>
|
||||
<div class="card-actions">
|
||||
<el-tooltip content="查看详细数据" placement="top">
|
||||
<el-button type="success" :icon="View" circle size="small" @click="showTrendDetailData" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="刷新数据" placement="top">
|
||||
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshTrendData" :loading="loading.trend" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="exceedanceTrendChartRef" class="chart" v-if="!loading.trend" />
|
||||
<el-skeleton :rows="8" animated v-else />
|
||||
</el-card>
|
||||
|
||||
<!-- Pollutant Trend Chart -->
|
||||
<el-card shadow="never" class="grid-item chart-card pollutant-trend" :body-style="{ padding: '16px', height: 'calc(100% - 53px)' }">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span>污染物趋势</span>
|
||||
<div class="card-actions">
|
||||
<el-select v-model="selectedPollutantType" placeholder="选择污染物" size="small" style="width: 120px; margin-right: 10px;" @change="handlePollutantTypeChange">
|
||||
<el-option v-for="item in pollutantTypes" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-tooltip content="查看详细数据" placement="top">
|
||||
<el-button type="success" :icon="View" circle size="small" @click="showPollutantDetailData" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="刷新数据" placement="top">
|
||||
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshPollutantTrendData" :loading="loading.pollutantTrend" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="pollutantTrendChartRef" class="chart" v-if="!loading.pollutantTrend && !errors.pollutantTrend" />
|
||||
<el-skeleton :rows="8" animated v-else-if="loading.pollutantTrend" />
|
||||
<el-result v-else icon="error" title="数据加载失败" sub-title="无法加载污染物趋势数据,请稍后重试。">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="refreshPollutantTrendData">重试</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</el-card>
|
||||
|
||||
<!-- Grid Coverage Table -->
|
||||
<el-card
|
||||
shadow="never"
|
||||
class="grid-item table-card grid-coverage"
|
||||
body-style="padding: 16px; height: calc(100% - 53px); box-sizing: border-box;"
|
||||
header-class="coverage-header"
|
||||
body-class="coverage-body"
|
||||
footer-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Grid /></el-icon>
|
||||
<span>网格覆盖率 (按城市)</span>
|
||||
<div class="card-actions">
|
||||
<el-tooltip content="刷新数据" placement="top">
|
||||
<el-button type="primary" :icon="Refresh" circle size="small" @click="refreshCoverageData" :loading="loading.coverage" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
:data="gridCoverageData"
|
||||
style="width: 100%"
|
||||
height="100%"
|
||||
v-if="!loading.coverage"
|
||||
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
|
||||
:row-class-name="coverageTableRowClassName"
|
||||
border
|
||||
>
|
||||
<el-table-column prop="city" label="城市" />
|
||||
<el-table-column prop="coverageRate" label="覆盖率" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="row.coverageRate"
|
||||
:color="getCoverageColor(row.coverageRate)"
|
||||
:stroke-width="18"
|
||||
:show-text="true"
|
||||
:format="percentageFormat"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="coveredGrids" label="已覆盖" align="center" />
|
||||
<el-table-column prop="totalGrids" label="总网格" align="center" />
|
||||
</el-table>
|
||||
<el-skeleton :rows="5" animated v-else />
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="dashboard-footer">
|
||||
<p>© 2025 环境监测系统 - 实时数据更新于 {{ currentTime }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 详细数据对话框 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
:title="detailDialogTitle"
|
||||
width="80%"
|
||||
:before-close="closeDetailDialog"
|
||||
destroy-on-close
|
||||
class="detail-dialog"
|
||||
>
|
||||
<div class="detail-table-container">
|
||||
<el-table
|
||||
:data="detailTableData"
|
||||
border
|
||||
style="width: 100%"
|
||||
:header-cell-style="{
|
||||
background: 'linear-gradient(to right, #409EFF, #67C23A)',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
padding: '12px 8px',
|
||||
textAlign: 'center'
|
||||
}"
|
||||
:cell-style="{
|
||||
textAlign: 'center',
|
||||
padding: '12px 8px',
|
||||
fontSize: '14px'
|
||||
}"
|
||||
:row-class-name="tableRowClassName"
|
||||
highlight-current-row
|
||||
>
|
||||
<el-table-column
|
||||
v-for="column in detailTableColumns"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
:width="column.width"
|
||||
:align="column.align || 'center'"
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="detail-dialog-footer">
|
||||
<el-button @click="closeDetailDialog" plain>关闭</el-button>
|
||||
<el-button type="primary" @click="exportDetailData" :icon="Download">导出数据</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<div v-else class="welcome-container">
|
||||
<el-result
|
||||
icon="success"
|
||||
title="欢迎回来"
|
||||
:sub-title="`您好, ${user?.name || '用户'}!您当前的角色是 ${user?.role || '未知'},没有权限访问仪表盘。`"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="goBack">返回上一页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed, nextTick, onBeforeUnmount, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { PieChart, LineChart } from 'echarts/charts';
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
} from 'echarts/components';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getDashboardStats, getAqiDistribution, getTaskCompletionStats, getMonthlyExceedanceTrend, getGridCoverageByCity, getPollutantMonthlyTrends } from '@/api/dashboard';
|
||||
import type { DashboardStatsDTO, AqiDistributionDTO, TaskStatsDTO, TrendDataPointDTO, GridCoverageDTO } from '@/api/types';
|
||||
import { Briefcase, CircleCheck, WarnTriangleFilled, Grid, DataAnalysis, Finished, TrendCharts, Refresh, Setting, DataLine, View, Download } from '@element-plus/icons-vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
const canViewDashboard = computed(() => {
|
||||
if (!user.value) return false;
|
||||
return ['ADMIN', 'DECISION_MAKER'].includes(user.value.role);
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
echarts.use([
|
||||
CanvasRenderer,
|
||||
PieChart,
|
||||
LineChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
]);
|
||||
|
||||
// --- Chart Element Refs ---
|
||||
const aqiDistributionChartRef = ref<HTMLElement | null>(null);
|
||||
const taskStatusChartRef = ref<HTMLElement | null>(null);
|
||||
const exceedanceTrendChartRef = ref<HTMLElement | null>(null);
|
||||
const pollutantTrendChartRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// --- Timer Ref ---
|
||||
const timer = ref<number | null>(null);
|
||||
|
||||
// --- Loading & Data State ---
|
||||
const loading = reactive({
|
||||
stats: false,
|
||||
aqi: false,
|
||||
tasks: false,
|
||||
trend: false,
|
||||
coverage: false,
|
||||
pollutantTrend: false,
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
stats: false,
|
||||
aqi: false,
|
||||
tasks: false,
|
||||
trend: false,
|
||||
coverage: false,
|
||||
pollutantTrend: false,
|
||||
});
|
||||
|
||||
const statsData = ref<Partial<DashboardStatsDTO & TaskStatsDTO>>({});
|
||||
const aqiDistributionData = ref<AqiDistributionDTO[]>([]);
|
||||
const taskStatusData = ref<TaskStatsDTO>({ totalTasks: 0, completedTasks: 0, completionRate: 0 });
|
||||
const exceedanceTrendData = ref<TrendDataPointDTO[]>([]);
|
||||
const gridCoverageData = ref<GridCoverageDTO[]>([]);
|
||||
const pollutantTrendData = ref<Record<string, TrendDataPointDTO[]>>({});
|
||||
const currentTime = ref(new Date().toLocaleString());
|
||||
|
||||
// 污染物类型选择
|
||||
const selectedPollutantType = ref<string>('ALL');
|
||||
const pollutantTypes = [
|
||||
{ label: '全部污染物', value: 'ALL' },
|
||||
{ label: 'PM2.5', value: 'PM25' },
|
||||
{ label: '臭氧(O₃)', value: 'O3' },
|
||||
{ label: '二氧化氮(NO₂)', value: 'NO2' },
|
||||
{ label: '二氧化硫(SO₂)', value: 'SO2' },
|
||||
{ label: '其他污染物', value: 'OTHER' }
|
||||
];
|
||||
|
||||
// 处理污染物类型变化
|
||||
const handlePollutantTypeChange = () => {
|
||||
console.log('选择的污染物类型:', selectedPollutantType.value);
|
||||
// 先销毁旧的图表实例
|
||||
if (pollutantTrendChartRef.value) {
|
||||
const oldChart = echarts.getInstanceByDom(pollutantTrendChartRef.value);
|
||||
if (oldChart) {
|
||||
oldChart.dispose();
|
||||
}
|
||||
}
|
||||
// 然后重新初始化图表
|
||||
nextTick(() => {
|
||||
initPollutantTrendChart();
|
||||
});
|
||||
};
|
||||
|
||||
// --- Timer for current time ---
|
||||
const timeInterval = setInterval(() => {
|
||||
currentTime.value = new Date().toLocaleString();
|
||||
}, 1000);
|
||||
|
||||
// --- KPI Stats ---
|
||||
const kpiStats = computed(() => [
|
||||
{
|
||||
title: '待办任务总数',
|
||||
value: statsData.value?.totalTasks ?? 0,
|
||||
icon: Briefcase,
|
||||
color: '#409EFF',
|
||||
},
|
||||
{
|
||||
title: '待处理任务',
|
||||
value: (statsData.value?.totalTasks ?? 0) - (statsData.value?.completedTasks ?? 0),
|
||||
icon: CircleCheck,
|
||||
color: '#67C23A',
|
||||
},
|
||||
{
|
||||
title: '待人工审核反馈',
|
||||
value: statsData.value?.totalFeedbacks ?? 0,
|
||||
icon: WarnTriangleFilled,
|
||||
color: '#E6A23C',
|
||||
},
|
||||
{
|
||||
title: '活动用户数',
|
||||
value: statsData.value?.activeGridWorkers ?? 0,
|
||||
icon: Grid,
|
||||
color: '#F56C6C',
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Chart Logic ---
|
||||
|
||||
const getAqiColor = (level: string): string => {
|
||||
const colorMap: { [key: string]: string } = {
|
||||
'Good': '#67c23a',
|
||||
'Moderate': '#f5c85b',
|
||||
'Unhealthy for Sensitive Groups': '#f5a623',
|
||||
'Unhealthy': '#d0021b',
|
||||
'Very Unhealthy': '#8b0000',
|
||||
'Hazardous': '#4a0000',
|
||||
};
|
||||
return colorMap[level] || '#999';
|
||||
};
|
||||
|
||||
const initAqiChart = () => {
|
||||
if (aqiDistributionChartRef.value && aqiDistributionData.value.length > 0) {
|
||||
const chart = echarts.init(aqiDistributionChartRef.value);
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
top: '85%',
|
||||
left: 'center',
|
||||
textStyle: { fontWeight: 'bold' },
|
||||
formatter: function(name: string) {
|
||||
// 如果名称太长,截断并添加省略号
|
||||
return name.length > 10 ? name.slice(0, 10) + '...' : name;
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: 'AQI 分布',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '40%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: '{d}%',
|
||||
overflow: 'truncate',
|
||||
width: 80
|
||||
},
|
||||
labelLine: {
|
||||
length: 15,
|
||||
length2: 10,
|
||||
smooth: true
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: '18', fontWeight: 'bold' },
|
||||
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
||||
},
|
||||
data: aqiDistributionData.value.map(item => ({
|
||||
value: item.count,
|
||||
name: item.level,
|
||||
itemStyle: { color: getAqiColor(item.level) }
|
||||
})),
|
||||
}]
|
||||
});
|
||||
|
||||
// 响应式调整
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
}
|
||||
};
|
||||
|
||||
const initTaskChart = () => {
|
||||
if (taskStatusChartRef.value && taskStatusData.value.totalTasks > 0) {
|
||||
const chart = echarts.init(taskStatusChartRef.value);
|
||||
const notCompleted = taskStatusData.value.totalTasks - taskStatusData.value.completedTasks;
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
top: '85%',
|
||||
left: 'center',
|
||||
textStyle: { fontWeight: 'bold' }
|
||||
},
|
||||
series: [{
|
||||
name: '任务状态',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '40%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: '{d}%',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: '20', fontWeight: 'bold' },
|
||||
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: taskStatusData.value.completedTasks,
|
||||
name: '已完成',
|
||||
itemStyle: {
|
||||
color: '#67c23a',
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
value: notCompleted,
|
||||
name: '未完成',
|
||||
itemStyle: {
|
||||
color: '#f56c6c',
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
}
|
||||
},
|
||||
],
|
||||
}]
|
||||
});
|
||||
|
||||
// 响应式调整
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
}
|
||||
};
|
||||
|
||||
const initTrendChart = () => {
|
||||
if (exceedanceTrendChartRef.value && exceedanceTrendData.value.length > 0) {
|
||||
const chart = echarts.init(exceedanceTrendChartRef.value);
|
||||
|
||||
// 确保数据格式正确
|
||||
console.log("趋势数据详情:", JSON.stringify(exceedanceTrendData.value));
|
||||
|
||||
// 准备数据
|
||||
const xAxisData = exceedanceTrendData.value.map(p => p.yearMonth || '未知');
|
||||
const seriesData = exceedanceTrendData.value.map(p => p.count || 0);
|
||||
|
||||
console.log("X轴数据:", xAxisData);
|
||||
console.log("系列数据:", seriesData);
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '过去12个月污染超标情况',
|
||||
left: 'center',
|
||||
top: 0,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#303133'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
shadowStyle: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
}
|
||||
},
|
||||
formatter: function(params: any) {
|
||||
const data = params[0];
|
||||
return `${data.name}<br/>${data.seriesName}: <strong>${data.value}</strong> 次`;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: xAxisData,
|
||||
axisLabel: {
|
||||
interval: 'auto',
|
||||
rotate: 45,
|
||||
formatter: function(value: string) {
|
||||
if (!value || value === '未知') return '未知';
|
||||
// 将 yyyy-MM 格式转换为 yyyy年MM月
|
||||
const parts = value.split('-');
|
||||
if (parts.length === 2) {
|
||||
return `${parts[0]}年${parts[1]}月`;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
margin: 14,
|
||||
hideOverlap: true
|
||||
},
|
||||
name: '',
|
||||
nameTextStyle: {
|
||||
fontWeight: 'bold',
|
||||
color: '#606266'
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#DCDFE6'
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
alignWithLabel: true
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '',
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: '#DCDFE6'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: '#EBEEF5'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
formatter: '{value} 次'
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: '超标次数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: seriesData,
|
||||
markPoint: {
|
||||
data: [
|
||||
{ type: 'max', name: '最大值' },
|
||||
{ type: 'min', name: '最小值' }
|
||||
],
|
||||
symbolSize: 60,
|
||||
itemStyle: {
|
||||
color: '#F56C6C',
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
markLine: {
|
||||
data: [{ type: 'average', name: '平均值' }],
|
||||
lineStyle: {
|
||||
color: '#E6A23C',
|
||||
type: 'dashed',
|
||||
width: 2
|
||||
},
|
||||
label: {
|
||||
formatter: '平均: {c} 次',
|
||||
position: 'middle',
|
||||
color: '#E6A23C',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
padding: [4, 8],
|
||||
borderRadius: 4
|
||||
}
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0, color: 'rgba(245, 108, 108, 0.7)'
|
||||
}, {
|
||||
offset: 0.5, color: 'rgba(245, 108, 108, 0.3)'
|
||||
}, {
|
||||
offset: 1, color: 'rgba(245, 108, 108, 0.05)'
|
||||
}]
|
||||
},
|
||||
shadowColor: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowBlur: 10
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#f56c6c',
|
||||
width: 4,
|
||||
shadowColor: 'rgba(245, 108, 108, 0.5)',
|
||||
shadowBlur: 10
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#f56c6c',
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 5
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderWidth: 6,
|
||||
shadowBlur: 20
|
||||
},
|
||||
lineStyle: {
|
||||
width: 6
|
||||
}
|
||||
},
|
||||
// 增加动画效果,但速度更慢
|
||||
animationDuration: 3000,
|
||||
animationEasing: 'cubicOut',
|
||||
animationDelay: function (idx: number) {
|
||||
return idx * 300;
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
// 应用配置
|
||||
chart.setOption(option);
|
||||
|
||||
// 响应式调整
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
} else {
|
||||
console.warn("无法初始化趋势图表,元素不存在或数据为空",
|
||||
{ hasRef: !!exceedanceTrendChartRef.value, dataLength: exceedanceTrendData.value.length });
|
||||
}
|
||||
};
|
||||
|
||||
const initPollutantTrendChart = () => {
|
||||
nextTick(() => {
|
||||
if (pollutantTrendChartRef.value && Object.keys(pollutantTrendData.value).length > 0) {
|
||||
const chart = echarts.init(pollutantTrendChartRef.value);
|
||||
|
||||
// 确保数据格式正确
|
||||
console.log("污染物趋势数据详情:", JSON.stringify(pollutantTrendData.value));
|
||||
console.log("当前选择的污染物类型:", selectedPollutantType.value);
|
||||
|
||||
// 污染物类型对应的颜色
|
||||
const colorMap: Record<string, string> = {
|
||||
'PM25': '#ff7f50',
|
||||
'O3': '#6a5acd',
|
||||
'NO2': '#32cd32',
|
||||
'SO2': '#ffd700',
|
||||
'OTHER': '#808080'
|
||||
};
|
||||
|
||||
// 获取所有月份
|
||||
const allMonths = new Set<string>();
|
||||
Object.keys(pollutantTrendData.value).forEach(type => {
|
||||
pollutantTrendData.value[type].forEach(point => {
|
||||
if (point.yearMonth) {
|
||||
allMonths.add(point.yearMonth);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为数组并排序
|
||||
const xAxisData = Array.from(allMonths).sort();
|
||||
|
||||
// 准备系列数据
|
||||
let series = [];
|
||||
|
||||
// 设置动画时间,全部显示时稍慢,单个显示时较快
|
||||
const animationDuration = selectedPollutantType.value === 'ALL' ? 2000 : 1200;
|
||||
const animationDelayTime = selectedPollutantType.value === 'ALL' ? 200 : 100;
|
||||
|
||||
if (selectedPollutantType.value === 'ALL') {
|
||||
// 显示所有污染物
|
||||
series = Object.keys(pollutantTrendData.value).map(type => {
|
||||
// 创建一个映射,方便查找
|
||||
const dataMap = new Map<string, number>();
|
||||
pollutantTrendData.value[type].forEach(point => {
|
||||
if (point.yearMonth) {
|
||||
dataMap.set(point.yearMonth, point.count / 100); // 除以100,因为后端乘以了100
|
||||
}
|
||||
});
|
||||
|
||||
// 生成系列数据
|
||||
const seriesData = xAxisData.map(month => dataMap.get(month) || 0);
|
||||
|
||||
return {
|
||||
name: getPollutantDisplayName(type),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
data: seriesData,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: colorMap[type] || '#5470c6'
|
||||
},
|
||||
itemStyle: {
|
||||
color: colorMap[type] || '#5470c6'
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
lineStyle: {
|
||||
width: 5
|
||||
}
|
||||
},
|
||||
animationDelay: (idx: number) => idx * animationDelayTime
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// 检查选择的污染物类型是否有数据
|
||||
if (!pollutantTrendData.value[selectedPollutantType.value]) {
|
||||
chart.setOption({
|
||||
title: {
|
||||
text: `暂无${getPollutantDisplayName(selectedPollutantType.value)}趋势数据`,
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
color: '#909399'
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 准备数据
|
||||
const trendData = pollutantTrendData.value[selectedPollutantType.value];
|
||||
|
||||
// 获取月份和数据
|
||||
const singleXAxisData = trendData.map(point => point.yearMonth || '未知').sort();
|
||||
const seriesData = trendData.map(point => point.count / 100); // 除以100,因为后端乘以了100
|
||||
|
||||
const color = colorMap[selectedPollutantType.value] || '#5470c6';
|
||||
|
||||
series = [{
|
||||
name: getPollutantDisplayName(selectedPollutantType.value),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
data: seriesData,
|
||||
markPoint: {
|
||||
data: [
|
||||
{ type: 'max', name: '最大值' },
|
||||
{ type: 'min', name: '最小值' }
|
||||
],
|
||||
symbolSize: 60,
|
||||
itemStyle: {
|
||||
color: color,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
markLine: {
|
||||
data: [{ type: 'average', name: '平均值' }],
|
||||
lineStyle: {
|
||||
color: '#E6A23C',
|
||||
type: 'dashed',
|
||||
width: 2
|
||||
},
|
||||
label: {
|
||||
formatter: '平均: {c}',
|
||||
position: 'middle',
|
||||
color: '#E6A23C',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
padding: [4, 8],
|
||||
borderRadius: 4
|
||||
}
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0, color: `${color}CC` // 80% 透明度
|
||||
}, {
|
||||
offset: 0.5, color: `${color}66` // 40% 透明度
|
||||
}, {
|
||||
offset: 1, color: `${color}11` // 10% 透明度
|
||||
}]
|
||||
},
|
||||
shadowColor: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowBlur: 10
|
||||
},
|
||||
lineStyle: {
|
||||
color: color,
|
||||
width: 4,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
shadowBlur: 10
|
||||
},
|
||||
itemStyle: {
|
||||
color: color,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 5
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderWidth: 6,
|
||||
shadowBlur: 20
|
||||
},
|
||||
lineStyle: {
|
||||
width: 6
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: selectedPollutantType.value === 'ALL' ? '所有污染物浓度月度趋势' : `${getPollutantDisplayName(selectedPollutantType.value)}浓度月度趋势`,
|
||||
left: 'center',
|
||||
top: 0,
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'normal',
|
||||
color: '#303133'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985'
|
||||
}
|
||||
},
|
||||
formatter: function(params: any) {
|
||||
if (params.length === 0) return '';
|
||||
|
||||
let result = params[0].name + '<br/>';
|
||||
params.forEach((param: any) => {
|
||||
result += `${param.marker} ${param.seriesName}: <strong>${param.value.toFixed(2)}</strong><br/>`;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: selectedPollutantType.value === 'ALL'
|
||||
? Object.keys(pollutantTrendData.value).map(type => getPollutantDisplayName(type))
|
||||
: [getPollutantDisplayName(selectedPollutantType.value)],
|
||||
top: selectedPollutantType.value === 'ALL' ? '10%' : '5%',
|
||||
type: 'scroll',
|
||||
textStyle: {
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
top: selectedPollutantType.value === 'ALL' ? '25%' : '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: xAxisData,
|
||||
axisLabel: {
|
||||
interval: 'auto',
|
||||
rotate: 45,
|
||||
formatter: function(value: string) {
|
||||
if (!value || value === '未知') return '未知';
|
||||
// 将 yyyy-MM 格式转换为 yyyy年MM月
|
||||
const parts = value.split('-');
|
||||
if (parts.length === 2) {
|
||||
return `${parts[0]}年${parts[1]}月`;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
margin: 14,
|
||||
hideOverlap: true
|
||||
},
|
||||
name: '',
|
||||
nameTextStyle: {
|
||||
fontWeight: 'bold',
|
||||
color: '#606266'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '',
|
||||
axisLine: {
|
||||
show: true
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
formatter: '{value} μg/m³',
|
||||
margin: 16
|
||||
}
|
||||
},
|
||||
series: series,
|
||||
animationDuration: animationDuration,
|
||||
animationEasing: 'cubicOut' as const,
|
||||
animationDelay: function(idx: number) {
|
||||
return idx * animationDelayTime;
|
||||
}
|
||||
};
|
||||
|
||||
// 应用配置
|
||||
chart.setOption(option);
|
||||
|
||||
// 响应式调整
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
} else {
|
||||
console.warn("无法初始化污染物趋势图表,元素不存在或数据为空",
|
||||
{ hasRef: !!pollutantTrendChartRef.value, dataLength: Object.keys(pollutantTrendData.value).length });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// --- Utility Functions ---
|
||||
const getCoverageColor = (percentage: number) => {
|
||||
if (percentage < 50) return '#f56c6c';
|
||||
if (percentage < 80) return '#e6a23c';
|
||||
return '#67c23a';
|
||||
};
|
||||
|
||||
// 获取污染物显示名称
|
||||
const getPollutantDisplayName = (type: string): string => {
|
||||
const nameMap: Record<string, string> = {
|
||||
'PM25': 'PM2.5',
|
||||
'O3': '臭氧(O₃)',
|
||||
'NO2': '二氧化氮(NO₂)',
|
||||
'SO2': '二氧化硫(SO₂)',
|
||||
'OTHER': '其他污染物'
|
||||
};
|
||||
|
||||
return nameMap[type] || type;
|
||||
};
|
||||
|
||||
const percentageFormat = (percentage: number) => {
|
||||
return `${percentage}%`;
|
||||
};
|
||||
|
||||
const tableRowClassName = ({ row, rowIndex }: { row: any, rowIndex: number }) => {
|
||||
if (rowIndex % 2 === 0) {
|
||||
return 'even-row';
|
||||
}
|
||||
return 'odd-row';
|
||||
};
|
||||
|
||||
const coverageTableRowClassName = ({ row }: { row: any }) => {
|
||||
if (row.coverageRate < 50) return 'coverage-row-low';
|
||||
if (row.coverageRate < 80) return 'coverage-row-medium';
|
||||
return 'coverage-row-high';
|
||||
};
|
||||
|
||||
// --- Data Fetching ---
|
||||
const refreshAqiData = () => {
|
||||
loading.aqi = true;
|
||||
getAqiDistribution().then(data => {
|
||||
aqiDistributionData.value = data;
|
||||
}).catch(e => {
|
||||
console.error('获取AQI分布数据失败:', e);
|
||||
ElMessage.error('获取AQI分布数据失败');
|
||||
}).finally(() => {
|
||||
loading.aqi = false;
|
||||
nextTick(initAqiChart);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshTaskData = () => {
|
||||
loading.tasks = true;
|
||||
getTaskCompletionStats().then(data => {
|
||||
taskStatusData.value = data;
|
||||
}).catch(e => {
|
||||
console.error('获取任务统计数据失败:', e);
|
||||
ElMessage.error('获取任务统计数据失败');
|
||||
}).finally(() => {
|
||||
loading.tasks = false;
|
||||
nextTick(initTaskChart);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshTrendData = () => {
|
||||
loading.trend = true;
|
||||
getMonthlyExceedanceTrend().then(data => {
|
||||
console.log("获取到的月度趋势数据:", JSON.stringify(data));
|
||||
exceedanceTrendData.value = data;
|
||||
}).catch(e => {
|
||||
console.error('获取月度趋势数据失败:', e);
|
||||
ElMessage.error('获取月度趋势数据失败');
|
||||
}).finally(() => {
|
||||
loading.trend = false;
|
||||
nextTick(() => {
|
||||
console.log("准备初始化趋势图,数据:", JSON.stringify(exceedanceTrendData.value));
|
||||
initTrendChart();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const refreshCoverageData = () => {
|
||||
loading.coverage = true;
|
||||
getGridCoverageByCity().then(data => {
|
||||
gridCoverageData.value = data;
|
||||
}).catch(e => {
|
||||
console.error('获取网格覆盖率数据失败:', e);
|
||||
ElMessage.error('获取网格覆盖率数据失败');
|
||||
}).finally(() => {
|
||||
loading.coverage = false;
|
||||
});
|
||||
};
|
||||
|
||||
const refreshPollutantTrendData = async () => {
|
||||
console.log('开始获取污染物趋势数据...');
|
||||
loading.pollutantTrend = true;
|
||||
errors.pollutantTrend = false;
|
||||
try {
|
||||
const data = await getPollutantMonthlyTrends(selectedPollutantType.value);
|
||||
console.log('获取到的污染物趋势数据:', data);
|
||||
pollutantTrendData.value = data;
|
||||
} catch (error) {
|
||||
console.error('获取污染物趋势数据失败:', error);
|
||||
errors.pollutantTrend = true;
|
||||
pollutantTrendData.value = {}; // Clear data on error, ensure it's an object
|
||||
} finally {
|
||||
loading.pollutantTrend = false;
|
||||
console.log('污染物趋势数据获取流程结束。');
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAllData = () => {
|
||||
console.log("开始刷新所有数据...");
|
||||
|
||||
loading.stats = true;
|
||||
loading.aqi = true;
|
||||
loading.tasks = true;
|
||||
loading.trend = true;
|
||||
loading.coverage = true;
|
||||
loading.pollutantTrend = true;
|
||||
|
||||
getDashboardStats().then(data => {
|
||||
console.log("获取到的关键指标数据:", data);
|
||||
statsData.value = data;
|
||||
}).catch(e => {
|
||||
console.error('获取关键指标失败:', e);
|
||||
ElMessage.error('获取关键指标失败');
|
||||
}).finally(() => {
|
||||
loading.stats = false;
|
||||
});
|
||||
|
||||
refreshAqiData();
|
||||
refreshTaskData();
|
||||
refreshTrendData();
|
||||
refreshCoverageData();
|
||||
refreshPollutantTrendData();
|
||||
|
||||
console.log("所有数据刷新请求已发送");
|
||||
};
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
watch(pollutantTrendData, (newData) => {
|
||||
if (newData && Object.keys(newData).length > 0) {
|
||||
initPollutantTrendChart();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
onMounted(() => {
|
||||
console.log("组件挂载,开始加载数据...");
|
||||
if (canViewDashboard.value) {
|
||||
refreshAllData();
|
||||
timer.value = setInterval(refreshAllData, 300000); // 5分钟刷新一次
|
||||
} else {
|
||||
console.log(`用户角色: ${user.value?.role}, 无需加载仪表盘数据。`);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
clearInterval(timeInterval);
|
||||
});
|
||||
|
||||
// --- Navigation ---
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 详细数据对话框
|
||||
const detailDialogVisible = ref(false);
|
||||
const detailDialogTitle = ref('');
|
||||
const detailTableData = ref<any[]>([]);
|
||||
const detailTableColumns = ref<any[]>([]);
|
||||
|
||||
// 显示AQI详细数据
|
||||
const showAqiDetailData = () => {
|
||||
detailDialogTitle.value = 'AQI级别分布详细数据';
|
||||
detailTableData.value = aqiDistributionData.value.map(item => ({
|
||||
level: item.level,
|
||||
count: item.count,
|
||||
percentage: ((item.count / aqiDistributionData.value.reduce((sum, i) => sum + i.count, 0)) * 100).toFixed(2) + '%'
|
||||
}));
|
||||
detailTableColumns.value = [
|
||||
{ prop: 'level', label: 'AQI级别', width: '180' },
|
||||
{ prop: 'count', label: '记录数量', width: '120' },
|
||||
{ prop: 'percentage', label: '百分比', width: '120' }
|
||||
];
|
||||
detailDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 显示任务详细数据
|
||||
const showTaskDetailData = () => {
|
||||
detailDialogTitle.value = '任务状态分布详细数据';
|
||||
detailTableData.value = [
|
||||
{ status: '已完成', count: taskStatusData.value.completedTasks, percentage: taskStatusData.value.completionRate.toFixed(2) + '%' },
|
||||
{ status: '未完成', count: taskStatusData.value.totalTasks - taskStatusData.value.completedTasks, percentage: (100 - taskStatusData.value.completionRate).toFixed(2) + '%' }
|
||||
];
|
||||
detailTableColumns.value = [
|
||||
{ prop: 'status', label: '任务状态', width: '180' },
|
||||
{ prop: 'count', label: '任务数量', width: '120' },
|
||||
{ prop: 'percentage', label: '百分比', width: '120' }
|
||||
];
|
||||
detailDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 显示超标趋势详细数据
|
||||
const showTrendDetailData = () => {
|
||||
detailDialogTitle.value = '月度超标趋势详细数据';
|
||||
detailTableData.value = exceedanceTrendData.value.map(item => ({
|
||||
yearMonth: item.yearMonth || '未知',
|
||||
count: item.count
|
||||
})).sort((a, b) => a.yearMonth.localeCompare(b.yearMonth));
|
||||
detailTableColumns.value = [
|
||||
{ prop: 'yearMonth', label: '年月', width: '180' },
|
||||
{ prop: 'count', label: '超标次数', width: '120' }
|
||||
];
|
||||
detailDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 显示污染物趋势详细数据
|
||||
const showPollutantDetailData = () => {
|
||||
detailDialogTitle.value = '污染物趋势详细数据';
|
||||
|
||||
if (selectedPollutantType.value === 'ALL') {
|
||||
// 获取所有月份
|
||||
const allMonths = new Set<string>();
|
||||
Object.keys(pollutantTrendData.value).forEach(type => {
|
||||
pollutantTrendData.value[type].forEach(point => {
|
||||
if (point.yearMonth) {
|
||||
allMonths.add(point.yearMonth);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为数组并排序
|
||||
const months = Array.from(allMonths).sort();
|
||||
|
||||
// 创建数据映射
|
||||
const dataMap: Record<string, Record<string, number>> = {};
|
||||
months.forEach(month => {
|
||||
dataMap[month] = {};
|
||||
Object.keys(pollutantTrendData.value).forEach(type => {
|
||||
dataMap[month][type] = 0;
|
||||
});
|
||||
});
|
||||
|
||||
// 填充数据
|
||||
Object.keys(pollutantTrendData.value).forEach(type => {
|
||||
pollutantTrendData.value[type].forEach(point => {
|
||||
if (point.yearMonth) {
|
||||
dataMap[point.yearMonth][type] = point.count / 100;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为表格数据
|
||||
detailTableData.value = months.map(month => {
|
||||
const row: any = { yearMonth: month };
|
||||
Object.keys(pollutantTrendData.value).forEach(type => {
|
||||
row[type] = dataMap[month][type].toFixed(2);
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
// 创建表格列
|
||||
detailTableColumns.value = [
|
||||
{ prop: 'yearMonth', label: '年月', width: '120' },
|
||||
...Object.keys(pollutantTrendData.value).map(type => ({
|
||||
prop: type,
|
||||
label: getPollutantDisplayName(type),
|
||||
width: '120'
|
||||
}))
|
||||
];
|
||||
} else {
|
||||
// 单个污染物
|
||||
const type = selectedPollutantType.value;
|
||||
detailTableData.value = pollutantTrendData.value[type].map(item => ({
|
||||
yearMonth: item.yearMonth || '未知',
|
||||
value: (item.count / 100).toFixed(2)
|
||||
})).sort((a, b) => a.yearMonth.localeCompare(b.yearMonth));
|
||||
|
||||
detailTableColumns.value = [
|
||||
{ prop: 'yearMonth', label: '年月', width: '180' },
|
||||
{ prop: 'value', label: `${getPollutantDisplayName(type)}浓度 (μg/m³)`, width: '180' }
|
||||
];
|
||||
}
|
||||
|
||||
detailDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 关闭详细数据对话框
|
||||
const closeDetailDialog = () => {
|
||||
detailDialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 导出详细数据
|
||||
const exportDetailData = () => {
|
||||
// 创建CSV内容
|
||||
let csvContent = '';
|
||||
|
||||
// 添加表头
|
||||
csvContent += detailTableColumns.value.map(col => `"${col.label}"`).join(',') + '\n';
|
||||
|
||||
// 添加数据行
|
||||
detailTableData.value.forEach(row => {
|
||||
const rowData = detailTableColumns.value.map(col => {
|
||||
const value = row[col.prop];
|
||||
return `"${value !== undefined ? value : ''}"`;
|
||||
});
|
||||
csvContent += rowData.join(',') + '\n';
|
||||
});
|
||||
|
||||
// 创建Blob对象
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `${detailDialogTitle.value}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
// 添加到DOM并触发点击
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// 清理
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
ElMessage.success('数据导出成功');
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4ebf5 100%);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-header h1::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 24px;
|
||||
background: linear-gradient(to bottom, #409EFF, #67C23A);
|
||||
margin-right: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dashboard-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: auto auto auto auto;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background-color: #f5f7fa;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
border-radius: 12px;
|
||||
background-color: #fff;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
grid-column: span 1;
|
||||
grid-row: 1 / 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kpi-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, rgba(103, 194, 58, 0.6) 0%, rgba(103, 194, 58, 1) 100%);
|
||||
transform: scaleX(0.7);
|
||||
transform-origin: left;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.kpi-card:hover::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
grid-column: span 2;
|
||||
height: 350px; /* Ensure charts have a consistent height */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
height: 6px;
|
||||
background: linear-gradient(90deg, #409EFF, #67C23A);
|
||||
opacity: 0.7;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0 16px 0;
|
||||
border-bottom: 1px dashed #EBEEF5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.chart-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
position: relative;
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-overlay .el-icon {
|
||||
font-size: 24px;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.aqi-distribution {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
grid-column: 3 / 5;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.exceedance-trend {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 3 / 4;
|
||||
}
|
||||
|
||||
.pollutant-trend {
|
||||
grid-column: 3 / 5;
|
||||
grid-row: 3 / 4;
|
||||
}
|
||||
|
||||
.grid-coverage {
|
||||
grid-column: 1 / 5;
|
||||
grid-row: 4 / 5;
|
||||
}
|
||||
|
||||
.kpi-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
margin-right: 20px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kpi-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0) 70%);
|
||||
}
|
||||
|
||||
.kpi-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kpi-title {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kpi-value::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 0;
|
||||
width: 30px;
|
||||
height: 3px;
|
||||
background-color: #409EFF;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #303133;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-header span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 20px;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
background-color: #f8f9fb;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px;
|
||||
height: calc(100% - 53px); /* Adjust based on header height */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.coverage-header {
|
||||
background-color: #f8f9fb;
|
||||
color: #303133;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.coverage-body {
|
||||
padding: 16px;
|
||||
height: calc(100% - 53px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.coverage-row-low {
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.coverage-row-medium {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.coverage-row-high {
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.coverage-row-low td,
|
||||
.coverage-row-medium td,
|
||||
.coverage-row-high td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.el-progress-bar__outer) {
|
||||
border-radius: 10px;
|
||||
background-color: #ebeef5;
|
||||
}
|
||||
|
||||
:deep(.el-progress-bar__inner) {
|
||||
border-radius: 10px;
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
:deep(.el-progress__text) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background-color: #f8f9fb !important;
|
||||
color: #303133;
|
||||
font-weight: bold;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
:deep(.el-table--border) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-footer {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.even-row {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.odd-row {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.even-row td,
|
||||
.odd-row td {
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.even-row:hover td,
|
||||
.odd-row:hover td {
|
||||
background-color: #ecf5ff !important;
|
||||
}
|
||||
|
||||
.detail-dialog {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-dialog :deep(.el-dialog__header) {
|
||||
background: linear-gradient(135deg, #409EFF, #67C23A);
|
||||
padding: 20px;
|
||||
border-bottom: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-dialog :deep(.el-dialog__title) {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.detail-dialog :deep(.el-dialog__headerbtn) {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.detail-dialog :deep(.el-dialog__headerbtn .el-dialog__close) {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.detail-dialog :deep(.el-dialog__body) {
|
||||
padding: 24px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
background-color: #f9fafc;
|
||||
}
|
||||
|
||||
.detail-table-container {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-table-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
background: linear-gradient(90deg, #409EFF, #67C23A);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.detail-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background-color: #f9fafc;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.even-row {
|
||||
background-color: #f9fafc;
|
||||
}
|
||||
|
||||
.odd-row {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.even-row td,
|
||||
.odd-row td {
|
||||
transition: all 0.3s;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.even-row:hover td,
|
||||
.odd-row:hover td {
|
||||
background-color: #ecf5ff !important;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.welcome-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 80vh;
|
||||
}
|
||||
</style>
|
||||
240
ems-frontend/src/views/LoginView.vue
Normal file
240
ems-frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>环境监督系统</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login-form" label-position="top" autocomplete="off" @submit.prevent="handleLogin">
|
||||
<el-form-item label="邮箱" prop="email" class="form-item-tight">
|
||||
<el-input v-model="loginForm.email" placeholder="请输入邮箱" size="large" :prefix-icon="User" clearable autocomplete="off"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password" class="form-item-tight">
|
||||
<el-input type="password" v-model="loginForm.password" placeholder="请输入密码" size="large" :prefix-icon="Lock" show-password autocomplete="new-password"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item class="login-btn-item">
|
||||
<el-button type="primary" @click="handleLogin" :loading="loading" class="login-button" size="large">登 录</el-button>
|
||||
</el-form-item>
|
||||
<div class="extra-links">
|
||||
<el-link type="info" @click="$router.push('/register')">注册账号</el-link>
|
||||
<el-link type="info" @click="$router.push('/forgot-password')">找回密码</el-link>
|
||||
</div>
|
||||
|
||||
<el-form-item class="guest-btn-item">
|
||||
<el-button @click="handleGuestAccess" class="guest-button" size="large" :icon="Promotion">作为游客提交反馈</el-button>
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { User, Lock, Promotion } from '@element-plus/icons-vue';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const loginFormRef = ref<FormInstance>();
|
||||
const loading = ref(false);
|
||||
|
||||
const loginForm = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const loginRules = reactive<FormRules>({
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] }
|
||||
],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return;
|
||||
await loginFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.login(loginForm);
|
||||
ElMessage.success('登录成功,正在跳转...');
|
||||
} catch (error) {
|
||||
ElMessage.error('登录失败,请检查您的邮箱和密码。');
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleGuestAccess = () => {
|
||||
router.push('/public-feedback');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden; /* Prevent scrolling */
|
||||
background-image: url('/login-bg.avif');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 480px;
|
||||
border-radius: 25px;
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
|
||||
animation: fadeIn 0.7s ease-in-out;
|
||||
background: rgba(245, 245, 245, 0.85); /* Light warm gray */
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: #004d40; /* Teal Gold color */
|
||||
padding: 25px 0 0 0; /* Removed bottom padding */
|
||||
border-bottom: 1px solid rgba(0,77,64,0.2);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 10px 35px 20px 35px; /* Minimal top padding */
|
||||
}
|
||||
|
||||
.form-item-tight {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: #004d40; /* Teal Gold color */
|
||||
color: white;
|
||||
letter-spacing: 5px;
|
||||
padding-left: 10px; /* to compensate letter-spacing */
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 77, 64, 0.4);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: #00695c;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 77, 64, 0.5);
|
||||
}
|
||||
|
||||
.login-btn-item {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.el-input__wrapper {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset !important;
|
||||
}
|
||||
|
||||
.extra-links {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.guest-btn-item {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.guest-button {
|
||||
width: 100%;
|
||||
border-color: #004d40;
|
||||
color: #004d40;
|
||||
}
|
||||
|
||||
.guest-button:hover {
|
||||
background-color: #e0f2f1;
|
||||
border-color: #00695c;
|
||||
color: #00695c;
|
||||
}
|
||||
|
||||
.extra-links .el-link {
|
||||
color: #bbb;
|
||||
font-size: 15px; /* Increased font size */
|
||||
}
|
||||
.extra-links .el-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* This is a global style override to achieve the desired hover/focus effect */
|
||||
.el-input__wrapper {
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary, #409eff) inset !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary, #409eff) inset !important;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
color: #555 !important;
|
||||
font-weight: 500 !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
color: #333 !important;
|
||||
font-size: 15px !important; /* Matched font size to label */
|
||||
}
|
||||
|
||||
.el-input__icon {
|
||||
font-size: 18px !important; /* Icon size can be slightly larger */
|
||||
}
|
||||
|
||||
.el-input__wrapper {
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
</style>
|
||||
12
ems-frontend/src/views/MapView.vue
Normal file
12
ems-frontend/src/views/MapView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>地图模块</h1>
|
||||
<p>这里是地图模块页面。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
58
ems-frontend/src/views/MyOperationLogView.vue
Normal file
58
ems-frontend/src/views/MyOperationLogView.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>我的操作日志</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="logs" v-loading="loading" style="width: 100%" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="ipAddress" label="IP地址" width="150"></el-table-column>
|
||||
<el-table-column prop="operationTypeDesc" label="操作类型" width="120"></el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
|
||||
<el-table-column prop="targetId" label="对象ID" width="100"></el-table-column>
|
||||
<el-table-column prop="targetType" label="对象类型" width="120"></el-table-column>
|
||||
<el-table-column prop="createdAt" label="操作时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getMyOperationLogs } from '@/api/log';
|
||||
import type { OperationLog } from '@/api/types';
|
||||
|
||||
const loading = ref(true);
|
||||
const logs = ref<OperationLog[]>([]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
logs.value = await getMyOperationLogs();
|
||||
} catch (error) {
|
||||
ElMessage.error('获取操作日志失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeStr: string) => {
|
||||
if (!timeStr) return '';
|
||||
return new Date(timeStr).toLocaleString();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchLogs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* You can add styles if needed */
|
||||
</style>
|
||||
228
ems-frontend/src/views/MyTasksView.vue
Normal file
228
ems-frontend/src/views/MyTasksView.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="my-tasks-view">
|
||||
<el-card class="page-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>我的任务</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<div class="filter-container">
|
||||
<el-radio-group v-model="selectedStatus" @change="fetchTasks">
|
||||
<el-radio-button value="">全部</el-radio-button>
|
||||
<el-radio-button value="ASSIGNED">待接受</el-radio-button>
|
||||
<el-radio-button value="IN_PROGRESS">进行中</el-radio-button>
|
||||
<el-radio-button value="SUBMITTED">已提交</el-radio-button>
|
||||
<el-radio-button value="COMPLETED">已完成</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<el-table :data="tasks" v-loading="loading" stripe class="task-table" @row-click="handleRowClick">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTagType(row.status)">{{ formatStatus(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="textAddress" label="地址" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="severity" label="严重程度" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getSeverityTagType(row.severity)">{{ formatSeverity(row.severity) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="分配时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ new Date(row.createdAt).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="viewTaskDetails(row.id)">查看详情</el-button>
|
||||
<el-button v-if="row.status === 'ASSIGNED'" type="success" link size="small" @click="acceptTask(row.id)">接受</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
class="pagination-container"
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import apiClient from '@/api';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { Page } from '@/api/types';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
// --- 类型定义 ---
|
||||
interface TaskSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
assigneeName: string | null;
|
||||
createdAt: string;
|
||||
textAddress: string;
|
||||
imageUrl: string;
|
||||
severity: string;
|
||||
}
|
||||
|
||||
// --- 响应式状态 ---
|
||||
const tasks = ref<TaskSummary[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedStatus = ref<string | null>(null);
|
||||
const total = ref(0);
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
// --- API 调用 ---
|
||||
const fetchTasks = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.page - 1,
|
||||
size: pagination.size,
|
||||
sort: 'createdAt,desc'
|
||||
};
|
||||
if (selectedStatus.value) {
|
||||
params.status = selectedStatus.value;
|
||||
}
|
||||
const response = await apiClient.get<Page<TaskSummary>>('/worker', { params });
|
||||
tasks.value = response.content;
|
||||
total.value = response.totalElements;
|
||||
} catch (error) {
|
||||
console.error('获取任务列表失败:', error);
|
||||
ElMessage.error('获取任务列表失败');
|
||||
tasks.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const acceptTask = async (taskId: number) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要接受这个任务吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info',
|
||||
});
|
||||
await apiClient.post(`/worker/${taskId}/accept`);
|
||||
ElMessage.success('任务已接受');
|
||||
fetchTasks(); // 刷新列表
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('接受任务失败:', error);
|
||||
ElMessage.error('接受任务失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- 辅助函数 ---
|
||||
const formatStatus = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'ASSIGNED': '待接受',
|
||||
'IN_PROGRESS': '进行中',
|
||||
'SUBMITTED': '已提交',
|
||||
'COMPLETED': '已完成',
|
||||
'REJECTED': '已拒绝',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getStatusTagType = (status: string): 'primary' | 'warning' | 'info' | 'success' | 'danger' => {
|
||||
const map: Record<string, 'primary' | 'warning' | 'info' | 'success' | 'danger'> = {
|
||||
'ASSIGNED': 'primary',
|
||||
'IN_PROGRESS': 'warning',
|
||||
'SUBMITTED': 'info',
|
||||
'COMPLETED': 'success',
|
||||
'REJECTED': 'danger',
|
||||
};
|
||||
return map[status] || 'info';
|
||||
};
|
||||
|
||||
const formatSeverity = (severity: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'LOW': '低',
|
||||
'MEDIUM': '中',
|
||||
'HIGH': '高',
|
||||
};
|
||||
return map[severity] || severity;
|
||||
};
|
||||
|
||||
const getSeverityTagType = (severity: string): 'info' | 'warning' | 'danger' => {
|
||||
const map: Record<string, 'info' | 'warning' | 'danger'> = {
|
||||
'LOW': 'info',
|
||||
'MEDIUM': 'warning',
|
||||
'HIGH': 'danger',
|
||||
};
|
||||
return map[severity] || 'info';
|
||||
};
|
||||
|
||||
// --- 事件处理 ---
|
||||
const viewTaskDetails = (taskId: number) => {
|
||||
router.push(`/my-tasks/${taskId}`);
|
||||
};
|
||||
|
||||
const handleRowClick = (row: TaskSummary) => {
|
||||
viewTaskDetails(row.id);
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.size = size;
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page;
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
// --- 生命周期钩子 ---
|
||||
onMounted(() => {
|
||||
fetchTasks();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-tasks-view {
|
||||
padding: 20px;
|
||||
}
|
||||
.page-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.task-table {
|
||||
width: 100%;
|
||||
}
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
106
ems-frontend/src/views/OperationLogView.vue
Normal file
106
ems-frontend/src/views/OperationLogView.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>操作日志</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="filter-container">
|
||||
<el-input v-model="filters.userId" placeholder="按用户ID搜索" style="width: 200px;"></el-input>
|
||||
<el-select v-model="filters.operationType" placeholder="按操作类型筛选" clearable>
|
||||
<el-option v-for="item in operationTypes" :key="item.value" :label="item.label" :value="item.value"></el-option>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filters.timeRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期">
|
||||
</el-date-picker>
|
||||
<el-button type="primary" :icon="Search" @click="fetchLogs">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" v-loading="loading" style="width: 100%" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="userName" label="用户名" width="120"></el-table-column>
|
||||
<el-table-column prop="ipAddress" label="IP地址" width="150"></el-table-column>
|
||||
<el-table-column prop="operationTypeDesc" label="操作类型" width="120"></el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
|
||||
<el-table-column prop="targetId" label="对象ID" width="100"></el-table-column>
|
||||
<el-table-column prop="targetType" label="对象类型" width="120"></el-table-column>
|
||||
<el-table-column prop="createdAt" label="操作时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatTime(scope.row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import { getOperationLogs } from '@/api/log';
|
||||
import type { OperationLog } from '@/api/types';
|
||||
|
||||
const loading = ref(true);
|
||||
const logs = ref<OperationLog[]>([]);
|
||||
const filters = reactive({
|
||||
userId: '',
|
||||
operationType: '',
|
||||
timeRange: [] as [Date, Date] | [],
|
||||
});
|
||||
|
||||
const operationTypes = [
|
||||
{ label: "登录", value: "LOGIN" },
|
||||
{ label: "登出", value: "LOGOUT" },
|
||||
{ label: "创建", value: "CREATE" },
|
||||
{ label: "更新", value: "UPDATE" },
|
||||
{ label: "删除", value: "DELETE" },
|
||||
{ label: "分配任务", value: "ASSIGN_TASK" },
|
||||
{ label: "提交任务", value: "SUBMIT_TASK" },
|
||||
{ label: "审批任务", value: "APPROVE_TASK" },
|
||||
{ label: "拒绝任务", value: "REJECT_TASK" },
|
||||
{ label: "提交反馈", value: "SUBMIT_FEEDBACK" },
|
||||
{ label: "审批反馈", value: "APPROVE_FEEDBACK" },
|
||||
{ label: "拒绝反馈", value: "REJECT_FEEDBACK" },
|
||||
{ label: "其他操作", value: "OTHER" }
|
||||
];
|
||||
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = {
|
||||
userId: filters.userId || undefined,
|
||||
operationType: filters.operationType || undefined,
|
||||
startTime: filters.timeRange?.[0]?.toISOString(),
|
||||
endTime: filters.timeRange?.[1]?.toISOString(),
|
||||
};
|
||||
logs.value = await getOperationLogs(params);
|
||||
} catch (error) {
|
||||
ElMessage.error('获取操作日志失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeStr: string) => {
|
||||
if (!timeStr) return '';
|
||||
return new Date(timeStr).toLocaleString();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchLogs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
206
ems-frontend/src/views/PublicFeedbackSubmitView.vue
Normal file
206
ems-frontend/src/views/PublicFeedbackSubmitView.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="public-submit-container">
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>公共环境问题反馈</span>
|
||||
<p>感谢您为环境保护出一份力!您可以在此提交发现的环境问题,无需登录。</p>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="form" :rules="rules" ref="feedbackForm" label-width="120px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入反馈标题"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="问题描述" prop="description">
|
||||
<el-input type="textarea" :rows="4" v-model="form.description" placeholder="请详细描述您发现的问题"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="污染类型" prop="pollutionType">
|
||||
<el-select v-model="form.pollutionType" placeholder="请选择污染类型">
|
||||
<el-option v-for="(label, key) in pollutionTypeMap" :key="key" :label="label" :value="key"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="严重程度" prop="severityLevel">
|
||||
<el-rate v-model="rating" :max="3" :texts="['低', '中', '高']" show-text></el-rate>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系方式" prop="contactInfo">
|
||||
<el-input v-model="form.contactInfo" placeholder="选填,便于我们与您联系(邮箱或电话)"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="问题地址" prop="textAddress">
|
||||
<el-input v-model="form.textAddress" placeholder="请输入问题发生的地址"></el-input>
|
||||
</el-form-item>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="经度" prop="longitude">
|
||||
<el-input-number v-model="form.longitude" :precision="6" :step="0.000001" placeholder="请输入经度"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="纬度" prop="latitude">
|
||||
<el-input-number v-model="form.latitude" :precision="6" :step="0.000001" placeholder="请输入纬度"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="网格X" prop="gridX">
|
||||
<el-input-number v-model="form.gridX" :precision="0" :step="1" placeholder="请输入网格X坐标"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="网格Y" prop="gridY">
|
||||
<el-input-number v-model="form.gridY" :precision="0" :step="1" placeholder="请输入网格Y坐标"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="照片/视频附件">
|
||||
<el-upload
|
||||
ref="upload"
|
||||
class="upload-demo"
|
||||
action="#"
|
||||
:on-remove="handleRemove"
|
||||
:on-change="handleChange"
|
||||
:file-list="fileList"
|
||||
list-type="picture-card"
|
||||
:auto-upload="false"
|
||||
multiple
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="submitForm" :loading="loading">立即提交</el-button>
|
||||
<el-button @click="resetForm">重置表单</el-button>
|
||||
<el-button @click="$router.push('/login')">返回登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { FormInstance, FormRules, UploadFile } from 'element-plus';
|
||||
import { PollutionType, SeverityLevel } from '@/api/feedback';
|
||||
import { submitPublicFeedback } from '@/api/public'; // Assuming the function will be in a new api/public.ts
|
||||
|
||||
const feedbackForm = ref<FormInstance>();
|
||||
const loading = ref(false);
|
||||
const fileList = ref<UploadFile[]>([]);
|
||||
const rating = ref(2);
|
||||
|
||||
interface PublicFeedbackForm {
|
||||
title: string;
|
||||
description: string;
|
||||
pollutionType: PollutionType;
|
||||
severityLevel: SeverityLevel;
|
||||
contactInfo?: string;
|
||||
textAddress: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
gridX?: number;
|
||||
gridY?: number;
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
pollutionType: PollutionType.OTHER,
|
||||
contactInfo: '',
|
||||
textAddress: '',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
gridX: 0,
|
||||
gridY: 0,
|
||||
});
|
||||
|
||||
const pollutionTypeMap: Record<PollutionType, string> = {
|
||||
[PollutionType.PM25]: 'PM2.5',
|
||||
[PollutionType.O3]: '臭氧 (O3)',
|
||||
[PollutionType.NO2]: '二氧化氮 (NO2)',
|
||||
[PollutionType.SO2]: '二氧化硫 (SO2)',
|
||||
[PollutionType.OTHER]: '其他',
|
||||
};
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
description: [{ required: true, message: '请输入问题描述', trigger: 'blur' }],
|
||||
pollutionType: [{ required: true, message: '请选择污染类型', trigger: 'change' }],
|
||||
textAddress: [{ required: true, message: '请输入问题地址', trigger: 'blur' }],
|
||||
gridX: [{ required: true, type: 'number', message: '网格X必须为数字' }],
|
||||
gridY: [{ required: true, type: 'number', message: '网格Y必须为数字' }],
|
||||
});
|
||||
|
||||
const handleRemove = (file: UploadFile, fileListUpdated: UploadFile[]) => {
|
||||
fileList.value = fileListUpdated;
|
||||
};
|
||||
|
||||
const handleChange = (file: UploadFile, fileListUpdated: UploadFile[]) => {
|
||||
fileList.value = fileListUpdated;
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!feedbackForm.value) return;
|
||||
await feedbackForm.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true;
|
||||
try {
|
||||
let severity: SeverityLevel;
|
||||
if (rating.value === 1) severity = SeverityLevel.LOW;
|
||||
else if (rating.value === 2) severity = SeverityLevel.MEDIUM;
|
||||
else severity = SeverityLevel.HIGH;
|
||||
|
||||
const submissionData: PublicFeedbackForm = { ...form, severityLevel: severity };
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('feedback', new Blob([JSON.stringify(submissionData)], { type: 'application/json' }));
|
||||
|
||||
fileList.value.forEach(file => {
|
||||
if (file.raw) {
|
||||
formData.append('files', file.raw);
|
||||
}
|
||||
});
|
||||
|
||||
await submitPublicFeedback(formData);
|
||||
ElMessage.success('反馈提交成功!感谢您的贡献。');
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
ElMessage.error('提交失败,请稍后再试。');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
feedbackForm.value?.resetFields();
|
||||
fileList.value = [];
|
||||
rating.value = 2;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.public-submit-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
.box-card {
|
||||
width: 800px;
|
||||
}
|
||||
.card-header {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
.card-header p {
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
299
ems-frontend/src/views/RegisterView.vue
Normal file
299
ems-frontend/src/views/RegisterView.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<el-card class="register-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>创建您的账户</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" label-position="top" hide-required-asterisk autocomplete="off">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="registerForm.name" placeholder="您的真实姓名" size="large" autocomplete="off"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="registerForm.phone" placeholder="您的手机号" size="large" autocomplete="off"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="registerForm.email" placeholder="您的邮箱" size="large" autocomplete="off"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码" prop="verificationCode">
|
||||
<el-row :gutter="10" style="width: 100%;">
|
||||
<el-col :span="15" class="verification-code-input">
|
||||
<el-input v-model="registerForm.verificationCode" placeholder="请输入6位验证码" size="large"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="9">
|
||||
<el-button @click="sendVerificationCode" :disabled="isSendingCode || countdown > 0" :loading="isSendingCode" class="send-code-button" size="large">
|
||||
{{ countdown > 0 ? `${countdown}秒后重发` : '发送验证码' }}
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input type="password" v-model="registerForm.password" placeholder="设置您的密码" show-password size="large" autocomplete="new-password"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input type="password" v-model="registerForm.confirmPassword" placeholder="请再次输入您的密码" show-password size="large" autocomplete="new-password"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="注册身份" prop="role">
|
||||
<el-select v-model="registerForm.role" placeholder="请选择您的身份" style="width: 100%;" size="large">
|
||||
<el-option label="公众监督员" value="PUBLIC_SUPERVISOR"></el-option>
|
||||
<el-option label="网格员" value="GRID_WORKER"></el-option>
|
||||
<el-option label="业务主管" value="SUPERVISOR"></el-option>
|
||||
<el-option label="系统管理员" value="ADMIN"></el-option>
|
||||
<el-option label="决策者" value="DECISION_MAKER"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleRegister" :loading="isRegistering" class="register-button">立即注册</el-button>
|
||||
</el-form-item>
|
||||
<div class="extra-links">
|
||||
<el-link type="info" @click="$router.push('/login')">已有账户?返回登录</el-link>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { signup, sendVerificationCode as apiSendCode } from '@/api/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const registerFormRef = ref<FormInstance>();
|
||||
const isRegistering = ref(false);
|
||||
|
||||
const registerForm = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: '',
|
||||
verificationCode: '',
|
||||
});
|
||||
|
||||
const validatePass2 = (rule: any, value: any, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'));
|
||||
} else if (value !== registerForm.password) {
|
||||
callback(new Error("两次输入的密码不一致!"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请输入手机号'));
|
||||
} else if (!/^1\d{10}$/.test(value)) {
|
||||
callback(new Error('手机号必须是以1开头的11位数字'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const registerRules = reactive<FormRules>({
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }, { type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] }],
|
||||
phone: [{ required: true, validator: validatePhone, trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
confirmPassword: [{ validator: validatePass2, trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择注册身份', trigger: 'change' }],
|
||||
verificationCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const isSendingCode = ref(false);
|
||||
const countdown = ref(0);
|
||||
let timer: number | null = null;
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (!registerForm.email) {
|
||||
ElMessage.warning('请输入您的邮箱地址');
|
||||
return;
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(registerForm.email)) {
|
||||
ElMessage.warning('请输入有效的邮箱地址');
|
||||
return;
|
||||
}
|
||||
|
||||
isSendingCode.value = true;
|
||||
try {
|
||||
await apiSendCode(registerForm.email);
|
||||
ElMessage.success('验证码已发送,请注意查收');
|
||||
|
||||
countdown.value = 60;
|
||||
if(timer) clearInterval(timer);
|
||||
timer = setInterval(() => {
|
||||
if (countdown.value > 0) {
|
||||
countdown.value--;
|
||||
} else {
|
||||
if(timer) clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
ElMessage.error('验证码发送失败,请稍后重试');
|
||||
console.error(error);
|
||||
} finally {
|
||||
isSendingCode.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return;
|
||||
await registerFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
isRegistering.value = true;
|
||||
try {
|
||||
const { confirmPassword, ...signupData } = registerForm;
|
||||
await signup(signupData);
|
||||
ElMessage.success('注册成功!正在跳转到登录页面...');
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
ElMessage.error('注册失败,请检查您填写的信息。');
|
||||
console.error(error);
|
||||
} finally {
|
||||
isRegistering.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
overflow-y: auto; /* Allow scrolling only if card is too tall */
|
||||
padding: 40px 0;
|
||||
background-image: url('/register-bg.avif');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 550px; /* Wider card for more fields */
|
||||
border-radius: 25px;
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
|
||||
background: rgba(245, 245, 245, 0.85);
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: #004d40;
|
||||
padding: 25px 0 15px 0;
|
||||
border-bottom: 1px solid rgba(0,77,64,0.2);
|
||||
}
|
||||
|
||||
.register-button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: #004d40;
|
||||
color: white;
|
||||
letter-spacing: 5px;
|
||||
padding-left: 10px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 77, 64, 0.4);
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.register-button:hover {
|
||||
background: #00695c;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 77, 64, 0.5);
|
||||
}
|
||||
|
||||
.extra-links {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.extra-links .el-link {
|
||||
color: #555;
|
||||
font-size: 15px;
|
||||
}
|
||||
.extra-links .el-link:hover {
|
||||
color: #004d40;
|
||||
}
|
||||
|
||||
/* Add scoped styles for the verification code input and button */
|
||||
:deep(.verification-code-input .el-input__wrapper) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.send-code-button {
|
||||
width: 100%;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
background-color: #004d40;
|
||||
color: white;
|
||||
border-color: #004d40;
|
||||
}
|
||||
|
||||
.send-code-button:hover {
|
||||
background-color: #00695c;
|
||||
border-color: #00695c;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* Global overrides to match login page style */
|
||||
:deep(.register-container .el-input__wrapper),
|
||||
:deep(.register-container .el-select .el-input__wrapper) {
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
border-radius: 10px !important;
|
||||
height: 45px;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid transparent !important;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.register-container .el-input__wrapper:hover),
|
||||
:deep(.register-container .el-select .el-input__wrapper:hover) {
|
||||
border-color: #ccc !important;
|
||||
}
|
||||
|
||||
:deep(.register-container .el-input__wrapper.is-focus),
|
||||
:deep(.register-container .el-select .el-input__wrapper.is-focused) {
|
||||
border-color: #004d40 !important;
|
||||
box-shadow: 0 0 0 1px rgba(0, 77, 64, 0.2) !important;
|
||||
}
|
||||
|
||||
.register-container .el-form-item__label {
|
||||
color: #555 !important;
|
||||
font-weight: 500 !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
.register-container .el-input__inner {
|
||||
color: #333 !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.register-container .el-button {
|
||||
height: 45px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
217
ems-frontend/src/views/ResetPasswordView.vue
Normal file
217
ems-frontend/src/views/ResetPasswordView.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="reset-password-container">
|
||||
<el-card class="reset-password-card">
|
||||
<div class="card-header">
|
||||
<h2>重置密码</h2>
|
||||
<p>请输入您的验证码和新密码</p>
|
||||
</div>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
hide-required-asterisk
|
||||
@submit.prevent="handleSubmit"
|
||||
autocomplete="off"
|
||||
>
|
||||
<el-form-item label="邮箱地址" prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
placeholder="请输入您的邮箱地址"
|
||||
size="large"
|
||||
autocomplete="email"
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
placeholder="请输入6位邮箱验证码"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="password">
|
||||
<el-input
|
||||
type="password"
|
||||
v-model="form.password"
|
||||
placeholder="请输入您的新密码"
|
||||
show-password
|
||||
size="large"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认新密码" prop="confirmPassword">
|
||||
<el-input
|
||||
type="password"
|
||||
v-model="form.confirmPassword"
|
||||
placeholder="请再次输入新密码"
|
||||
show-password
|
||||
size="large"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="isLoading"
|
||||
class="submit-button"
|
||||
size="large"
|
||||
>
|
||||
确认重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="card-footer">
|
||||
<el-link @click="$router.push('/login')">返回登录</el-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { resetPasswordWithCode } from '@/api/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const formRef = ref<FormInstance>();
|
||||
const isLoading = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
code: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.email) {
|
||||
form.email = route.query.email as string;
|
||||
} else {
|
||||
ElMessage.error('无效的重置链接,请返回重试。');
|
||||
router.push('/forgot-password');
|
||||
}
|
||||
});
|
||||
|
||||
const validatePass = (rule: any, value: any, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'));
|
||||
} else if (value !== form.password) {
|
||||
callback(new Error("两次输入的密码不一致!"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] }
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ len: 6, message: '验证码必须是6位', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{ validator: validatePass, trigger: 'blur' }
|
||||
],
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return;
|
||||
formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await resetPasswordWithCode({
|
||||
email: form.email,
|
||||
code: form.code,
|
||||
newPassword: form.password,
|
||||
});
|
||||
ElMessage.success('密码重置成功!即将跳转到登录页...');
|
||||
setTimeout(() => router.push('/login'), 2000);
|
||||
} catch (error) {
|
||||
ElMessage.error('密码重置失败,请检查验证码是否正确。');
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reset-password-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background-image: url('/reset-password-bg.avif');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.reset-password-card {
|
||||
width: 480px;
|
||||
border-radius: 25px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
color: #004d40;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
color: #607d8b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
background-color: #004d40;
|
||||
border-color: #004d40;
|
||||
transition: background-color 0.3s ease;
|
||||
font-size: 1rem;
|
||||
padding: 1.2rem 0;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background-color: #00695c;
|
||||
border-color: #00695c;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
192
ems-frontend/src/views/SubmitFeedbackView.vue
Normal file
192
ems-frontend/src/views/SubmitFeedbackView.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="submit-feedback-container">
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>提交新的环境问题反馈</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="form" :rules="rules" ref="feedbackForm" label-width="120px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入反馈标题"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="问题描述" prop="description">
|
||||
<el-input type="textarea" :rows="4" v-model="form.description" placeholder="请详细描述您发现的问题"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="污染类型" prop="pollutionType">
|
||||
<el-select v-model="form.pollutionType" placeholder="请选择污染类型">
|
||||
<el-option v-for="(label, key) in pollutionTypeMap" :key="key" :label="label" :value="key"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="严重程度" prop="severityLevel">
|
||||
<el-rate v-model="rating" :max="3" :texts="['低', '中', '高']" show-text></el-rate>
|
||||
</el-form-item>
|
||||
<el-form-item label="问题地址" prop="location.textAddress">
|
||||
<el-input v-model="form.location.textAddress" placeholder="请输入问题发生的地址"></el-input>
|
||||
</el-form-item>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="经度" prop="location.longitude">
|
||||
<el-input-number v-model="form.location.longitude" :precision="6" :step="0.000001" placeholder="请输入经度"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="纬度" prop="location.latitude">
|
||||
<el-input-number v-model="form.location.latitude" :precision="6" :step="0.000001" placeholder="请输入纬度"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="网格X" prop="location.gridX">
|
||||
<el-input-number v-model="form.location.gridX" :precision="0" :step="1" placeholder="请输入网格X坐标"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="网格Y" prop="location.gridY">
|
||||
<el-input-number v-model="form.location.gridY" :precision="0" :step="1" placeholder="请输入网格Y坐标"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="照片/视频附件">
|
||||
<el-upload
|
||||
ref="upload"
|
||||
class="upload-demo"
|
||||
action="#"
|
||||
:on-preview="handlePreview"
|
||||
:on-remove="handleRemove"
|
||||
:on-change="handleChange"
|
||||
:file-list="fileList"
|
||||
list-type="picture-card"
|
||||
:auto-upload="false"
|
||||
multiple
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="submitForm" :loading="loading">立即提交</el-button>
|
||||
<el-button @click="resetForm">重置表单</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { FormInstance, FormRules, UploadFile } from 'element-plus';
|
||||
import { useFeedbackStore } from '@/store/feedback';
|
||||
import { PollutionType } from '@/api/types';
|
||||
import { SeverityLevel } from '@/api/feedback';
|
||||
import type { FeedbackSubmissionRequest, LocationInfo } from '@/api/feedback';
|
||||
|
||||
const feedbackForm = ref<FormInstance>();
|
||||
const loading = ref(false);
|
||||
const fileList = ref<UploadFile[]>([]);
|
||||
const rating = ref(2); // For el-rate which uses a number. 1=LOW, 2=MEDIUM, 3=HIGH
|
||||
|
||||
const form = reactive<Omit<FeedbackSubmissionRequest, 'severityLevel'>>({
|
||||
title: '',
|
||||
description: '',
|
||||
pollutionType: PollutionType.PM25,
|
||||
location: {
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
textAddress: '',
|
||||
gridX: 0,
|
||||
gridY: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const pollutionTypeMap: Record<PollutionType, string> = {
|
||||
[PollutionType.PM25]: 'PM2.5',
|
||||
[PollutionType.O3]: '臭氧 (O3)',
|
||||
[PollutionType.NO2]: '二氧化氮 (NO2)',
|
||||
[PollutionType.SO2]: '二氧化硫 (SO2)',
|
||||
[PollutionType.OTHER]: '其他',
|
||||
};
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
description: [{ required: true, message: '请输入问题描述', trigger: 'blur' }],
|
||||
pollutionType: [{ required: true, message: '请选择污染类型', trigger: 'change' }],
|
||||
'location.textAddress': [{ required: true, message: '请输入问题地址', trigger: 'blur' }],
|
||||
'location.latitude': [{ required: true, type: 'number', message: '纬度必须为数字' }],
|
||||
'location.longitude': [{ required: true, type: 'number', message: '经度必须为数字' }],
|
||||
'location.gridX': [{ required: true, type: 'number', message: '网格X必须为数字' }],
|
||||
'location.gridY': [{ required: true, type: 'number', message: '网格Y必须为数字' }],
|
||||
});
|
||||
|
||||
const feedbackStore = useFeedbackStore();
|
||||
|
||||
const handleRemove = (file: UploadFile, fileListUpdated: UploadFile[]) => {
|
||||
fileList.value = fileListUpdated;
|
||||
};
|
||||
|
||||
const handlePreview = (file: UploadFile) => {
|
||||
console.log(file);
|
||||
};
|
||||
|
||||
const handleChange = (file: UploadFile, fileListUpdated: UploadFile[]) => {
|
||||
fileList.value = fileListUpdated;
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!feedbackForm.value) return;
|
||||
await feedbackForm.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true;
|
||||
try {
|
||||
let severity: SeverityLevel;
|
||||
if (rating.value === 1) {
|
||||
severity = SeverityLevel.LOW;
|
||||
} else if (rating.value === 2) {
|
||||
severity = SeverityLevel.MEDIUM;
|
||||
} else {
|
||||
severity = SeverityLevel.HIGH;
|
||||
}
|
||||
|
||||
const submissionData: FeedbackSubmissionRequest = {
|
||||
...form,
|
||||
severityLevel: severity,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('feedback', new Blob([JSON.stringify(submissionData)], { type: 'application/json' }));
|
||||
|
||||
fileList.value.forEach(file => {
|
||||
if (file.raw) {
|
||||
formData.append('files', file.raw);
|
||||
}
|
||||
});
|
||||
|
||||
await feedbackStore.submitFeedback(formData);
|
||||
ElMessage.success('反馈提交成功!感谢您的贡献。');
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
ElMessage.error('提交失败,请稍后再试。');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
feedbackForm.value?.resetFields();
|
||||
fileList.value = [];
|
||||
rating.value = 2;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.submit-feedback-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
97
ems-frontend/src/views/SupervisorView.vue
Normal file
97
ems-frontend/src/views/SupervisorView.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="supervisor-view">
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>待审核反馈</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="pendingFeedback" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="title" label="标题"></el-table-column>
|
||||
<el-table-column prop="description" label="描述" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="pollutionType" label="污染类型" width="120"></el-table-column>
|
||||
<el-table-column prop="severityLevel" label="严重等级" width="120"></el-table-column>
|
||||
<el-table-column prop="createdAt" label="提交时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDateTime(scope.row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="handleCreateTask(scope.row)">创建任务</el-button>
|
||||
<el-button size="small" type="danger" @click="handleRejectFeedback(scope.row)">拒绝</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- TODO: Add Task Management Section -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { FeedbackResponse } from '@/api/feedback';
|
||||
import { getPendingFeedback, processFeedback } from '@/api/feedback'; // Assuming processFeedback exists
|
||||
import { formatDateTime } from '@/utils/formatter';
|
||||
|
||||
const pendingFeedback = ref<FeedbackResponse[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const fetchPendingFeedback = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getPendingFeedback();
|
||||
pendingFeedback.value = response;
|
||||
} catch (error) {
|
||||
ElMessage.error('获取待审核反馈失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTask = (feedback: FeedbackResponse) => {
|
||||
// TODO: Implement task creation logic
|
||||
console.log('Create task from feedback:', feedback.id);
|
||||
ElMessage.info(`准备为反馈 #${feedback.id} 创建任务`);
|
||||
};
|
||||
|
||||
const handleRejectFeedback = async (feedback: FeedbackResponse) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要拒绝这条反馈吗? (ID: ${feedback.id})`, '确认操作', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
// This assumes a 'processFeedback' function exists to update feedback status
|
||||
// to something like 'REJECTED' or 'CLOSED_INVALID'.
|
||||
// We may need to create or adjust this backend logic.
|
||||
await processFeedback(feedback.id, { status: 'CLOSED_INVALID' }); // Placeholder status
|
||||
ElMessage.success('反馈已拒绝');
|
||||
fetchPendingFeedback(); // Refresh list
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPendingFeedback();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.supervisor-view {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
12
ems-frontend/src/views/SystemSettingsView.vue
Normal file
12
ems-frontend/src/views/SystemSettingsView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>系统设置</h1>
|
||||
<p>这里是系统设置页面。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
289
ems-frontend/src/views/TaskDetailView.vue
Normal file
289
ems-frontend/src/views/TaskDetailView.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="task-detail-view" v-if="task">
|
||||
<el-card class="page-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>任务详情: {{ task.title }}</span>
|
||||
<el-tag :type="getStatusTagType(task.status)">{{ formatStatus(task.status) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="任务ID">{{ task.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="严重程度">
|
||||
<el-tag :type="getSeverityTagType(task.severityLevel)">{{ formatSeverity(task.severityLevel) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="污染类型">{{ task.pollutionType || '未指定' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网格坐标">X: {{ task.gridX }}, Y: {{ task.gridY }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分配时间">{{ task.assignedAt ? new Date(task.assignedAt).toLocaleString() : 'N/A' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完成时间">{{ task.completedAt ? new Date(task.completedAt).toLocaleString() : 'N/A' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址" :span="2">{{ task.textAddress || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务描述" :span="2">{{ task.description }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="task.feedback">
|
||||
<el-divider />
|
||||
<h3>关联反馈信息</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="反馈ID">{{ task.feedback.feedbackId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交人">{{ task.feedback.submitterName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="反馈时间">{{ new Date(task.feedback.createdAt).toLocaleString() }}</el-descriptions-item>
|
||||
<el-descriptions-item label="反馈标题">{{ task.feedback.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="反馈描述" :span="2">{{ task.feedback.description }}</el-descriptions-item>
|
||||
<el-descriptions-item label="现场图片" :span="2" v-if="task.feedback.imageUrls && task.feedback.imageUrls.length > 0">
|
||||
<el-image
|
||||
v-for="url in task.feedback.imageUrls"
|
||||
:key="url"
|
||||
:src="url"
|
||||
:preview-src-list="task.feedback.imageUrls"
|
||||
style="width: 100px; height: 100px; margin-right: 10px;"
|
||||
fit="cover"
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<h3>任务历史</h3>
|
||||
<div v-if="task.history && task.history.length > 0">
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="item in task.history"
|
||||
:key="item.id"
|
||||
:timestamp="new Date(item.changedAt).toLocaleString()"
|
||||
:type="getHistoryType(item.newStatus)"
|
||||
>
|
||||
<strong>{{ formatStatus(item.newStatus) }}</strong>
|
||||
<p>{{ item.comments }}</p>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
<el-empty v-else description="暂无历史记录" />
|
||||
|
||||
<div v-if="task.submissionInfo">
|
||||
<el-divider />
|
||||
<h3>任务提交详情</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="提交说明">{{ task.submissionInfo.comments }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交附件" v-if="task.submissionInfo.attachments && task.submissionInfo.attachments.length > 0">
|
||||
<div v-for="att in task.submissionInfo.attachments" :key="att.id">
|
||||
<el-link :href="att.url" type="primary" target="_blank">{{ att.fileName }}</el-link>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button v-if="task.status === 'ASSIGNED'" type="success" @click="acceptTask" :loading="actionLoading">接受任务</el-button>
|
||||
<el-button v-if="task.status === 'IN_PROGRESS'" type="primary" @click="openSubmitDialog" :loading="actionLoading">提交进度</el-button>
|
||||
<el-button @click="$router.back()">返回列表</el-button>
|
||||
</div>
|
||||
|
||||
</el-card>
|
||||
</div>
|
||||
<div v-else-if="loading">
|
||||
<el-card class="page-card" v-loading="true" style="min-height: 400px;"></el-card>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-card class="page-card">
|
||||
<el-empty description="无法加载任务详情" />
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<SubmitProgressDialog
|
||||
v-if="task"
|
||||
v-model:visible="isSubmitDialogVisible"
|
||||
:task-id="task.id"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import apiClient from '@/api';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import SubmitProgressDialog from '@/components/SubmitProgressDialog.vue';
|
||||
|
||||
// --- 类型定义 ---
|
||||
interface TaskHistoryDTO {
|
||||
id: number;
|
||||
changedAt: string;
|
||||
newStatus: string;
|
||||
comments: string;
|
||||
}
|
||||
interface FeedbackDTO {
|
||||
feedbackId: number;
|
||||
eventId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
severityLevel: string;
|
||||
textAddress: string;
|
||||
submitterId: number;
|
||||
submitterName: string;
|
||||
createdAt: string;
|
||||
imageUrls: string[];
|
||||
}
|
||||
interface AssigneeDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
interface AttachmentDTO {
|
||||
id: number;
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
url: string;
|
||||
}
|
||||
interface SubmissionInfoDTO {
|
||||
comments: string;
|
||||
attachments: AttachmentDTO[];
|
||||
}
|
||||
interface TaskDetail {
|
||||
id: number;
|
||||
feedback: FeedbackDTO;
|
||||
title: string;
|
||||
description: string;
|
||||
severityLevel: string;
|
||||
pollutionType: string;
|
||||
textAddress: string;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
status: string;
|
||||
assignee: AssigneeDTO;
|
||||
assignedAt: string | null;
|
||||
completedAt: string | null;
|
||||
history: TaskHistoryDTO[];
|
||||
submissionInfo: SubmissionInfoDTO | null;
|
||||
}
|
||||
|
||||
// --- 响应式状态 ---
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskId = ref(route.params.id as string);
|
||||
const task = ref<TaskDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const actionLoading = ref(false);
|
||||
const isSubmitDialogVisible = ref(false);
|
||||
|
||||
// --- API 调用 ---
|
||||
const fetchTaskDetails = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await apiClient.get<TaskDetail>(`/worker/${taskId.value}`);
|
||||
task.value = response;
|
||||
} catch (error) {
|
||||
console.error(`获取任务详情失败 (ID: ${taskId.value}):`, error);
|
||||
ElMessage.error('获取任务详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const acceptTask = async () => {
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要接受这个任务吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info',
|
||||
});
|
||||
await apiClient.post(`/worker/${taskId.value}/accept`);
|
||||
ElMessage.success('任务已接受');
|
||||
fetchTaskDetails(); // Refresh details
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('接受任务失败:', error);
|
||||
ElMessage.error('接受任务失败');
|
||||
}
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openSubmitDialog = () => {
|
||||
isSubmitDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSubmitted = () => {
|
||||
fetchTaskDetails();
|
||||
};
|
||||
|
||||
// --- 辅助函数 ---
|
||||
const formatStatus = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'ASSIGNED': '待接受',
|
||||
'IN_PROGRESS': '进行中',
|
||||
'SUBMITTED': '已提交',
|
||||
'COMPLETED': '已完成',
|
||||
'REJECTED': '已拒绝',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getStatusTagType = (status: string): 'primary' | 'warning' | 'info' | 'success' | 'danger' => {
|
||||
const map: Record<string, 'primary' | 'warning' | 'info' | 'success' | 'danger'> = {
|
||||
'ASSIGNED': 'primary',
|
||||
'IN_PROGRESS': 'warning',
|
||||
'SUBMITTED': 'info',
|
||||
'COMPLETED': 'success',
|
||||
'REJECTED': 'danger',
|
||||
};
|
||||
return map[status] || 'info';
|
||||
};
|
||||
|
||||
const formatSeverity = (severity: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'LOW': '低',
|
||||
'MEDIUM': '中',
|
||||
'HIGH': '高',
|
||||
};
|
||||
return map[severity] || severity;
|
||||
};
|
||||
|
||||
const getSeverityTagType = (severity: string): 'info' | 'warning' | 'danger' => {
|
||||
const map: Record<string, 'info' | 'warning' | 'danger'> = {
|
||||
'LOW': 'info',
|
||||
'MEDIUM': 'warning',
|
||||
'HIGH': 'danger',
|
||||
};
|
||||
return map[severity] || 'info';
|
||||
};
|
||||
|
||||
const getHistoryType = (status: string): 'primary' | 'success' | 'info' => {
|
||||
switch (status) {
|
||||
case 'ASSIGNED':
|
||||
case 'IN_PROGRESS':
|
||||
return 'primary';
|
||||
case 'COMPLETED':
|
||||
return 'success';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchTaskDetails();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-detail-view {
|
||||
padding: 20px;
|
||||
}
|
||||
.page-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.action-buttons {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
12
ems-frontend/src/views/TaskView.vue
Normal file
12
ems-frontend/src/views/TaskView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>任务管理</h1>
|
||||
<p>这里是任务管理页面。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
437
ems-frontend/src/views/UserManagementView.vue
Normal file
437
ems-frontend/src/views/UserManagementView.vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>人员管理</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="table-header">
|
||||
<el-input v-model="searchName" placeholder="按姓名搜索" clearable @clear="fetchUsers" @keyup.enter="fetchUsers" style="width: 200px;"></el-input>
|
||||
<el-select v-model="searchRole" placeholder="按角色筛选" clearable @change="fetchUsers" style="width: 150px;">
|
||||
<el-option label="管理员" value="ADMIN"></el-option>
|
||||
<el-option label="决策者" value="DECISION_MAKER"></el-option>
|
||||
<el-option label="主管" value="SUPERVISOR"></el-option>
|
||||
<el-option label="网格员" value="GRID_WORKER"></el-option>
|
||||
<el-option label="公众监督员" value="PUBLIC_SUPERVISOR"></el-option>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="fetchUsers" :icon="Search">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="users" v-loading="loading" style="width: 100%" border stripe table-layout="auto" class="user-table compact-table">
|
||||
<el-table-column prop="id" label="ID" width="60" align="center"></el-table-column>
|
||||
<el-table-column prop="name" label="姓名" width="120" align="center"></el-table-column>
|
||||
<el-table-column prop="email" label="邮箱" min-width="180"></el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" width="120" align="center"></el-table-column>
|
||||
<el-table-column prop="gender" label="性别" width="70" align="center">
|
||||
<template #default="scope">
|
||||
{{ formatGender(scope.row.gender) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="150" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="roleTagType(scope.row.role)" size="small">{{ formatRole(scope.row.role) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTagType(scope.row.status)" size="small" disable-transitions>{{ scope.row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-popconfirm title="确定要删除这位用户吗?" @confirm="handleDelete(scope.row.id)">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
v-model:page-size="pagination.size"
|
||||
v-model:current-page="pagination.page"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
style="margin-top: 20px; justify-content: flex-end;"
|
||||
></el-pagination>
|
||||
|
||||
<!-- Edit User Dialog -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑用户信息" width="700px" :show-close="false" class="edit-dialog ultimate-dialog">
|
||||
<template #header="{ close, titleId, titleClass }">
|
||||
<div class="ultimate-dialog-header">
|
||||
<h4 :id="titleId" :class="titleClass">
|
||||
<el-icon class="header-icon"><EditPen /></el-icon>
|
||||
编辑用户信息
|
||||
</h4>
|
||||
<el-button circle :icon="Close" type="danger" class="header-close-btn" @click="close"></el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="editForm" ref="editFormRef" label-width="90px" label-position="top">
|
||||
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">身份凭证</h3>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="form-label-icon">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户ID</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-input v-model="editForm.id" disabled class="readonly-input"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="form-label-icon">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>姓名</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-input v-model="editForm.name" disabled class="readonly-input"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">详细信息</h3>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item prop="phone">
|
||||
<template #label>
|
||||
<div class="form-label-icon">
|
||||
<el-icon><Phone /></el-icon>
|
||||
<span>手机号</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-input v-model="editForm.phone" placeholder="请输入手机号"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item prop="gender">
|
||||
<template #label>
|
||||
<div class="form-label-icon">
|
||||
<el-icon><Male /></el-icon>
|
||||
<span>性别</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-radio-group v-model="editForm.gender">
|
||||
<el-radio label="MALE">男</el-radio>
|
||||
<el-radio label="FEMALE">女</el-radio>
|
||||
<el-radio label="OTHER">其他</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item prop="role">
|
||||
<template #label>
|
||||
<div class="form-label-icon">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>角色</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-select v-model="editForm.role" placeholder="选择角色" style="width:100%;">
|
||||
<el-option label="管理员" value="ADMIN"></el-option>
|
||||
<el-option label="决策者" value="DECISION_MAKER"></el-option>
|
||||
<el-option label="主管" value="SUPERVISOR"></el-option>
|
||||
<el-option label="网格员" value="GRID_WORKER"></el-option>
|
||||
<el-option label="公众监督员" value="PUBLIC_SUPERVISOR"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item prop="status">
|
||||
<template #label>
|
||||
<div class="form-label-icon">
|
||||
<el-icon><Switch /></el-icon>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-select v-model="editForm.status" placeholder="选择状态" style="width:100%;">
|
||||
<el-option label="ACTIVE" value="ACTIVE"></el-option>
|
||||
<el-option label="INACTIVE" value="INACTIVE"></el-option>
|
||||
<el-option label="SUSPENDED" value="SUSPENDED"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="editDialogVisible = false" plain>取 消</el-button>
|
||||
<el-button type="primary" @click="submitEdit" :loading="isSaving" :icon="Check">确 认 保 存</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Search, User, Avatar, Phone, Male, Key, Switch, Close, Check, EditPen } from '@element-plus/icons-vue';
|
||||
import { getUsers, updateUser, deleteUser } from '@/api/personnel';
|
||||
import type { UserAccount, UserUpdateRequest } from '@/api/types';
|
||||
|
||||
const users = ref<UserAccount[]>([]);
|
||||
const loading = ref(true);
|
||||
const total = ref(0);
|
||||
const searchName = ref('');
|
||||
const searchRole = ref('');
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
});
|
||||
|
||||
const editDialogVisible = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const editFormRef = ref();
|
||||
const editForm = reactive<Partial<UserAccount>>({});
|
||||
|
||||
const roleMap: Record<string, string> = {
|
||||
ADMIN: '管理员',
|
||||
DECISION_MAKER: '决策者',
|
||||
SUPERVISOR: '主管',
|
||||
GRID_WORKER: '网格员',
|
||||
PUBLIC_SUPERVISOR: '公众监督员'
|
||||
};
|
||||
|
||||
const formatRole = (role: string) => roleMap[role] || role;
|
||||
const formatGender = (gender: 'MALE' | 'FEMALE' | 'OTHER') => {
|
||||
if (gender === 'MALE') return '男';
|
||||
if (gender === 'FEMALE') return '女';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
const roleTagType = (role: string) => {
|
||||
switch (role) {
|
||||
case 'ADMIN': return 'danger';
|
||||
case 'DECISION_MAKER': return 'warning';
|
||||
case 'SUPERVISOR': return 'primary';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const statusTagType = (status: string) => {
|
||||
if (status === 'ACTIVE') return 'success';
|
||||
if (status === 'INACTIVE') return 'info';
|
||||
if (status === 'SUSPENDED') return 'warning';
|
||||
return '';
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page - 1,
|
||||
size: pagination.size,
|
||||
name: searchName.value || undefined,
|
||||
role: searchRole.value || undefined,
|
||||
};
|
||||
const response = await getUsers(params);
|
||||
users.value = response.content;
|
||||
total.value = response.totalElements;
|
||||
} catch (error) {
|
||||
console.error("获取用户列表时出错:", error);
|
||||
ElMessage.error('获取用户列表失败,请检查网络或联系管理员。');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (user: UserAccount) => {
|
||||
Object.assign(editForm, user);
|
||||
editDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!editForm.id) return;
|
||||
isSaving.value = true;
|
||||
|
||||
const updateData: UserUpdateRequest = {
|
||||
phone: editForm.phone,
|
||||
gender: editForm.gender as 'MALE' | 'FEMALE' | 'OTHER',
|
||||
role: editForm.role,
|
||||
status: editForm.status,
|
||||
};
|
||||
|
||||
try {
|
||||
await updateUser(editForm.id, updateData);
|
||||
ElMessage.success('用户信息更新成功');
|
||||
editDialogVisible.value = false;
|
||||
await fetchUsers();
|
||||
} catch (error) {
|
||||
console.error(`更新用户 #${editForm.id} 失败:`, error);
|
||||
ElMessage.error('更新失败,请重试。');
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: number) => {
|
||||
try {
|
||||
await deleteUser(userId);
|
||||
ElMessage.success('删除成功');
|
||||
await fetchUsers();
|
||||
} catch (error) {
|
||||
console.error(`删除用户 #${userId} 失败:`, error);
|
||||
ElMessage.error('删除失败,请重试。');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.size = size;
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page;
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.box-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.compact-table :deep(.el-table__cell) {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.user-table .el-button {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
margin-top: 20px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.el-table__row .el-button) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-table__row:hover .el-button) {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Ultimate Dialog Styles */
|
||||
.ultimate-dialog .el-dialog__header {
|
||||
display: none; /* Hide original header */
|
||||
}
|
||||
|
||||
.ultimate-dialog {
|
||||
--el-dialog-padding-primary: 0;
|
||||
background: linear-gradient(to top right, #e0f2f1, #ffffff);
|
||||
}
|
||||
|
||||
.ultimate-dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.ultimate-dialog-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.header-close-btn {
|
||||
--el-button-bg-color: transparent;
|
||||
--el-button-border-color: transparent;
|
||||
--el-button-hover-bg-color: var(--el-color-danger-light-8);
|
||||
--el-button-hover-border-color: var(--el-color-danger-light-8);
|
||||
}
|
||||
|
||||
.ultimate-dialog .el-dialog__body {
|
||||
padding: 24px 30px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px 0 rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 25px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-top: 0;
|
||||
margin-bottom: 24px;
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.form-label-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.readonly-input {
|
||||
--el-input-disabled-bg-color: #eef1f6;
|
||||
--el-input-disabled-text-color: #303133;
|
||||
--el-input-disabled-border-color: #dcdfe6;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
padding: 10px 30px 20px;
|
||||
}
|
||||
</style>
|
||||
84
ems-frontend/src/views/UserProfileView.vue
Normal file
84
ems-frontend/src/views/UserProfileView.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="profile-container">
|
||||
<el-card class="profile-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>个人信息</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="user" class="profile-content">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户ID" label-align="right" align="center" label-class-name="my-label">
|
||||
{{ user.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名" label-align="right" align="center">
|
||||
{{ user.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱" label-align="right" align="center">
|
||||
{{ user.email }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="电话" label-align="right" align="center">
|
||||
{{ user.phone || 'N/A' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色" label-align="right" align="center">
|
||||
<el-tag>{{ user.role }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" label-align="right" align="center">
|
||||
<el-tag :type="user.status === 'ACTIVE' ? 'success' : 'danger'">{{ user.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="所属区域" label-align="right" align="center" :span="2">
|
||||
{{ user.region || 'N/A' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="技能" label-align="right" align="center" :span="2">
|
||||
<template v-if="user.skills && user.skills.length">
|
||||
<el-tag v-for="skill in user.skills" :key="skill" type="info" style="margin-right: 8px;">
|
||||
{{ skill }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else>N/A</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>正在加载用户信息...</p>
|
||||
</div>
|
||||
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
// 从 Pinia store 中获取用户信息
|
||||
const authStore = useAuthStore();
|
||||
// 使用 storeToRefs 来保持响应性
|
||||
const { user } = storeToRefs(authStore);
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.profile-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.my-label {
|
||||
background: var(--el-color-success-light-9) !important;
|
||||
}
|
||||
</style>
|
||||
337
ems-frontend/src/views/WorkerMapView.vue
Normal file
337
ems-frontend/src/views/WorkerMapView.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="grid-management-container">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h2>我的网格地图</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="refreshData" :loading="loading || isLoadingWorkers" icon="Refresh">
|
||||
刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content-grid">
|
||||
<!-- Grid Map Area -->
|
||||
<div class="map-area" v-loading="loading">
|
||||
<div v-if="error" class="error-message">
|
||||
<el-alert title="地图数据加载失败" :description="error" type="error" show-icon :closable="false" />
|
||||
<el-button @click="fetchAllGrids" type="primary" class="retry-button">重试</el-button>
|
||||
</div>
|
||||
<svg v-else-if="displayGrids.length" :width="svgDimensions.width" :height="svgDimensions.height" class="grid-svg">
|
||||
<defs>
|
||||
<pattern id="grid-pattern-worker" :width="CELL_SIZE" :height="CELL_SIZE" patternUnits="userSpaceOnUse">
|
||||
<path :d="`M ${CELL_SIZE} 0 L 0 0 0 ${CELL_SIZE}`" fill="none" stroke="#e9e9e9" stroke-width="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect :width="svgDimensions.width" :height="svgDimensions.height" fill="url(#grid-pattern-worker)" />
|
||||
|
||||
<g v-for="grid in displayGrids" :key="grid.id">
|
||||
<rect
|
||||
:x="grid.displayX * CELL_SIZE"
|
||||
:y="grid.displayY * CELL_SIZE"
|
||||
:width="CELL_SIZE"
|
||||
:height="CELL_SIZE"
|
||||
:fill="getGridFillColor(grid)"
|
||||
@click="selectGrid(grid)"
|
||||
:class="{ 'selected': selectedGrid && selectedGrid.id === grid.id }"
|
||||
class="grid-cell"
|
||||
/>
|
||||
<!-- Border Rendering -->
|
||||
<line v-if="getBorder(grid, 'top')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="grid.displayY * CELL_SIZE" class="border-line" />
|
||||
<line v-if="getBorder(grid, 'right')" :x1="(grid.displayX + 1) * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
|
||||
<line v-if="getBorder(grid, 'bottom')" :x1="grid.displayX * CELL_SIZE" :y1="(grid.displayY + 1) * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
|
||||
<line v-if="getBorder(grid, 'left')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="grid.displayX * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
|
||||
</g>
|
||||
</svg>
|
||||
<el-empty v-else description="没有可显示的网格数据" />
|
||||
</div>
|
||||
|
||||
<!-- Details Sidebar -->
|
||||
<div class="sidebar-area">
|
||||
<el-card shadow="never" class="details-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>网格详情</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="selectedGrid" class="details-content">
|
||||
<p><strong>网格ID:</strong> {{ selectedGrid.id }}</p>
|
||||
<p><strong>城市:</strong> <el-tag :color="getCityColor(selectedGrid.cityName)" effect="light">{{ selectedGrid.cityName }}</el-tag></p>
|
||||
<p><strong>区县:</strong> {{ selectedGrid.districtName || 'N/A' }}</p>
|
||||
<p><strong>原始坐标:</strong> ({{ selectedGrid.gridX }}, {{ selectedGrid.gridY }})</p>
|
||||
<p><strong>是否为障碍物:</strong> <el-tag :type="selectedGrid.isObstacle ? 'danger' : 'success'">{{ selectedGrid.isObstacle ? '是' : '否' }}</el-tag></p>
|
||||
<p><strong>描述:</strong> {{ selectedGrid.description || '无' }}</p>
|
||||
<el-divider />
|
||||
<p><strong>负责人:</strong></p>
|
||||
<div v-if="selectedGridWorker">
|
||||
<el-tag effect="plain" type="info">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ selectedGridWorker.name }} ({{ selectedGridWorker.phone }})
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-tag type="warning">未分配</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="请在左侧地图上选择一个网格" />
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { Grid, UserAccount } from '@/api/types';
|
||||
import { getGrids } from '@/api/dashboard';
|
||||
import { getAllGridWorkers } from '@/api/personnel';
|
||||
import { User } from '@element-plus/icons-vue';
|
||||
|
||||
const CELL_SIZE = 25;
|
||||
const PADDING_CELLS = 2;
|
||||
|
||||
// --- State ---
|
||||
const gridData = ref<Grid[]>([]);
|
||||
const allWorkers = ref<UserAccount[]>([]);
|
||||
const loading = ref(false);
|
||||
const isLoadingWorkers = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const selectedGrid = ref<DisplayGrid | null>(null);
|
||||
|
||||
// --- Data Fetching & Refresh ---
|
||||
const fetchAllGrids = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
selectedGrid.value = null;
|
||||
try {
|
||||
const response = await getGrids();
|
||||
gridData.value = Array.isArray(response) ? response : (response as any).content || [];
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '获取网格数据时发生未知错误';
|
||||
ElMessage.error(error.value || '获取网格数据时发生未知错误');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllWorkers = async () => {
|
||||
isLoadingWorkers.value = true;
|
||||
try {
|
||||
allWorkers.value = await getAllGridWorkers();
|
||||
} catch (e: any) {
|
||||
ElMessage.error('获取网格员列表失败: ' + e.message);
|
||||
} finally {
|
||||
isLoadingWorkers.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([fetchAllGrids(), fetchAllWorkers()]);
|
||||
ElMessage.success('数据已刷新');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
});
|
||||
|
||||
// --- Computed Properties ---
|
||||
const workersByCoord = computed<Map<string, UserAccount>>(() => {
|
||||
const map = new Map<string, UserAccount>();
|
||||
allWorkers.value.forEach(worker => {
|
||||
if (worker.gridX !== null && worker.gridY !== null) {
|
||||
map.set(`${worker.gridX},${worker.gridY}`, worker);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const selectedGridWorker = computed<UserAccount | undefined>(() => {
|
||||
if (!selectedGrid.value || !allWorkers.value.length) {
|
||||
return undefined;
|
||||
}
|
||||
return allWorkers.value.find(
|
||||
worker => worker.gridX === selectedGrid.value?.gridX && worker.gridY === selectedGrid.value?.gridY
|
||||
);
|
||||
});
|
||||
|
||||
// --- City Color Logic ---
|
||||
const cityColors = ref<Map<string, string>>(new Map());
|
||||
const getCityColor = (cityName: string): string => {
|
||||
if (!cityColors.value.has(cityName)) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < cityName.length; i++) {
|
||||
hash = cityName.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const color = `hsl(${hash % 360}, 80%, 85%)`;
|
||||
cityColors.value.set(cityName, color);
|
||||
}
|
||||
return cityColors.value.get(cityName)!;
|
||||
};
|
||||
|
||||
const getGridFillColor = (grid: Grid): string => {
|
||||
if (grid.isObstacle) {
|
||||
return '#000000';
|
||||
}
|
||||
if (workersByCoord.value.has(`${grid.gridX},${grid.gridY}`)) {
|
||||
return '#FF0000';
|
||||
}
|
||||
return getCityColor(grid.cityName);
|
||||
};
|
||||
|
||||
// --- Display Logic ---
|
||||
interface DisplayGrid extends Grid {
|
||||
displayX: number;
|
||||
displayY: number;
|
||||
}
|
||||
|
||||
const displayGrids = computed<DisplayGrid[]>(() => {
|
||||
if (!gridData.value.length) return [];
|
||||
|
||||
// Determine the top-left corner of the entire map to normalize coordinates
|
||||
const minX = Math.min(...gridData.value.map(g => g.gridX));
|
||||
const minY = Math.min(...gridData.value.map(g => g.gridY));
|
||||
|
||||
// Render all grids based on their absolute positions, normalized to start at (0,0)
|
||||
return gridData.value.map(grid => ({
|
||||
...grid,
|
||||
displayX: grid.gridX - minX,
|
||||
displayY: grid.gridY - minY,
|
||||
}));
|
||||
});
|
||||
|
||||
const gridMap = computed(() => {
|
||||
const map = new Map<string, DisplayGrid>();
|
||||
displayGrids.value.forEach(grid => {
|
||||
map.set(`${grid.displayX},${grid.displayY}`, grid);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const svgDimensions = computed(() => {
|
||||
if (!displayGrids.value.length) return { width: 0, height: 0 };
|
||||
const maxX = Math.max(...displayGrids.value.map(g => g.displayX));
|
||||
const maxY = Math.max(...displayGrids.value.map(g => g.displayY));
|
||||
return {
|
||||
width: (maxX + 1) * CELL_SIZE,
|
||||
height: (maxY + 1) * CELL_SIZE,
|
||||
};
|
||||
});
|
||||
|
||||
const getBorder = (grid: DisplayGrid, side: 'top' | 'right' | 'bottom' | 'left'): boolean => {
|
||||
const { displayX, displayY, cityName } = grid;
|
||||
let neighbor: DisplayGrid | undefined;
|
||||
switch (side) {
|
||||
case 'top':
|
||||
neighbor = gridMap.value.get(`${displayX},${displayY - 1}`);
|
||||
break;
|
||||
case 'right':
|
||||
neighbor = gridMap.value.get(`${displayX + 1},${displayY}`);
|
||||
break;
|
||||
case 'bottom':
|
||||
neighbor = gridMap.value.get(`${displayX},${displayY + 1}`);
|
||||
break;
|
||||
case 'left':
|
||||
neighbor = gridMap.value.get(`${displayX - 1},${displayY}`);
|
||||
break;
|
||||
}
|
||||
return !neighbor || neighbor.cityName !== cityName;
|
||||
};
|
||||
|
||||
const selectGrid = (grid: DisplayGrid) => {
|
||||
selectedGrid.value = grid;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-management-container {
|
||||
padding: 20px;
|
||||
background-color: #f7f8fa;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.main-content-grid {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-area {
|
||||
flex: 3;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e9e9e9;
|
||||
}
|
||||
|
||||
.sidebar-area {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.details-card .card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grid-svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
stroke: #fff;
|
||||
stroke-width: 1;
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.grid-cell:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.grid-cell.selected {
|
||||
stroke: #ff4d4f;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.border-line {
|
||||
stroke: #333;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.details-content p {
|
||||
margin: 0 0 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.details-content p strong {
|
||||
color: #303133;
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
224
ems-frontend/src/views/WorkerTaskView.vue
Normal file
224
ems-frontend/src/views/WorkerTaskView.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="worker-task-view">
|
||||
<el-card class="page-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>我的任务</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<div class="filter-container">
|
||||
<el-radio-group v-model="selectedStatus" @change="fetchTasks">
|
||||
<el-radio-button :label="null">全部</el-radio-button>
|
||||
<el-radio-button label="ASSIGNED">已分配</el-radio-button>
|
||||
<el-radio-button label="IN_PROGRESS">进行中</el-radio-button>
|
||||
<el-radio-button label="SUBMITTED">已提交</el-radio-button>
|
||||
<el-radio-button label="COMPLETED">已完成</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<el-table :data="tasks" v-loading="loading" stripe class="task-table">
|
||||
<el-table-column prop="id" label="任务ID" width="80" />
|
||||
<el-table-column prop="title" label="任务标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTagType(row.status)">{{ formatStatus(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="textAddress" label="地址" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="severity" label="严重程度" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getSeverityTagType(row.severity)">{{ formatSeverity(row.severity) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="分配时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ new Date(row.createdAt).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="viewTaskDetails(row.id)">查看详情</el-button>
|
||||
<el-button v-if="row.status === 'ASSIGNED'" type="success" link size="small" @click="acceptTask(row.id)">接受</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
class="pagination-container"
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import apiClient from '@/api';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { Pageable } from '@/api/types';
|
||||
|
||||
// --- 类型定义 ---
|
||||
interface TaskSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
assigneeName: string | null;
|
||||
createdAt: string;
|
||||
textAddress: string;
|
||||
imageUrl: string;
|
||||
severity: string;
|
||||
}
|
||||
|
||||
// --- 响应式状态 ---
|
||||
const tasks = ref<TaskSummary[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedStatus = ref<string | null>(null);
|
||||
const total = ref(0);
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
});
|
||||
|
||||
// --- API 调用 ---
|
||||
const fetchTasks = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.page - 1,
|
||||
size: pagination.size,
|
||||
};
|
||||
if (selectedStatus.value) {
|
||||
params.status = selectedStatus.value;
|
||||
}
|
||||
// 注意:这里的API返回的是Page<T>,但Controller返回的是List<T>
|
||||
// 需要调整Controller或这里的处理方式。暂时假设Controller返回List
|
||||
const response = await apiClient.get<TaskSummary[]>('/worker', { params });
|
||||
tasks.value = response;
|
||||
// @ts-ignore
|
||||
// total.value = response.totalElements || response.length; // 假设 totalElements
|
||||
} catch (error) {
|
||||
console.error('获取任务列表失败:', error);
|
||||
ElMessage.error('获取任务列表失败');
|
||||
tasks.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const acceptTask = async (taskId: number) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要接受这个任务吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info',
|
||||
});
|
||||
await apiClient.post(`/worker/${taskId}/accept`);
|
||||
ElMessage.success('任务已接受');
|
||||
fetchTasks(); // 刷新列表
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('接受任务失败:', error);
|
||||
ElMessage.error('接受任务失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- 辅助函数 ---
|
||||
const formatStatus = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'ASSIGNED': '已分配',
|
||||
'IN_PROGRESS': '进行中',
|
||||
'SUBMITTED': '已提交',
|
||||
'COMPLETED': '已完成',
|
||||
'REJECTED': '已拒绝',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getStatusTagType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'ASSIGNED': 'primary',
|
||||
'IN_PROGRESS': 'warning',
|
||||
'SUBMITTED': 'info',
|
||||
'COMPLETED': 'success',
|
||||
'REJECTED': 'danger',
|
||||
};
|
||||
return map[status] || 'info';
|
||||
};
|
||||
|
||||
const formatSeverity = (severity: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'LOW': '低',
|
||||
'MEDIUM': '中',
|
||||
'HIGH': '高',
|
||||
};
|
||||
return map[severity] || severity;
|
||||
};
|
||||
|
||||
const getSeverityTagType = (severity: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'LOW': 'info',
|
||||
'MEDIUM': 'warning',
|
||||
'HIGH': 'danger',
|
||||
};
|
||||
return map[severity] || 'info';
|
||||
};
|
||||
|
||||
// --- 事件处理 ---
|
||||
const viewTaskDetails = (taskId: number) => {
|
||||
// TODO: 实现跳转到任务详情页或打开详情对话框
|
||||
ElMessage.info(`查看任务详情: ${taskId}`);
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.size = size;
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page;
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
// --- 生命周期钩子 ---
|
||||
onMounted(() => {
|
||||
fetchTasks();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.worker-task-view {
|
||||
padding: 20px;
|
||||
}
|
||||
.page-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.task-table {
|
||||
width: 100%;
|
||||
}
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user