- Add boardDeviceService for managing device pairing, status, claiming, and revocation. - Introduce boardDeviceSettings for default settings and validation logic. - Create integration tests for device lifecycle, concurrent claims, and updates. - Add unit tests for device pairing, claiming, and settings validation. - Implement API tests for device endpoints with JWT authentication. - Develop in-memory repository for testing purposes. - Create frontend components for managing devices, including a form for device settings. - Style DevicesPage with CSS for better user experience.
234 lines
8.5 KiB
JavaScript
234 lines
8.5 KiB
JavaScript
import axios from 'axios';
|
|
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
|
|
|
const API_BASE_URL = getApiBaseUrl();
|
|
const API_KEY = import.meta.env.VITE_API_KEY || 'dev_key_12345678';
|
|
|
|
const api = axios.create({
|
|
baseURL: API_BASE_URL,
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
// Request interceptor for authentication
|
|
api.interceptors.request.use(
|
|
config => {
|
|
console.log(`API Request: ${config.method.toUpperCase()} ${config.url}`);
|
|
|
|
// Check if this is an auth endpoint
|
|
const isAuthEndpoint = config.url?.startsWith('/auth/');
|
|
const isPublicAuthEndpoint = config.url === '/auth/login' ||
|
|
config.url === '/auth/refresh' ||
|
|
config.url === '/auth/logout';
|
|
|
|
// For auth endpoints (except protected ones), don't add any auth headers
|
|
if (isAuthEndpoint && isPublicAuthEndpoint) {
|
|
return config;
|
|
}
|
|
|
|
// For all other endpoints, try to use JWT token first
|
|
const accessToken = localStorage.getItem('accessToken');
|
|
const activeCityId = localStorage.getItem('activeCityId');
|
|
|
|
if (activeCityId) {
|
|
config.headers['X-City-Id'] = activeCityId;
|
|
}
|
|
|
|
if (accessToken) {
|
|
// Use JWT token if available
|
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
|
} else {
|
|
// Fall back to API key for non-authenticated requests
|
|
config.headers['X-API-Key'] = API_KEY;
|
|
}
|
|
|
|
return config;
|
|
},
|
|
error => {
|
|
console.error('API Request Error:', error);
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// Response interceptor for error handling and token refresh
|
|
api.interceptors.response.use(
|
|
response => response,
|
|
async error => {
|
|
const originalRequest = error.config;
|
|
|
|
// If 401 or 403 and we have a refresh token, try to refresh
|
|
if ((error.response?.status === 401 || error.response?.status === 403) &&
|
|
!originalRequest._retry &&
|
|
localStorage.getItem('refreshToken')) {
|
|
|
|
originalRequest._retry = true;
|
|
|
|
try {
|
|
const refreshToken = localStorage.getItem('refreshToken');
|
|
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
|
refreshToken
|
|
});
|
|
|
|
const { accessToken } = response.data;
|
|
localStorage.setItem('accessToken', accessToken);
|
|
|
|
// Retry original request with new token
|
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
|
return api(originalRequest);
|
|
} catch (refreshError) {
|
|
// Refresh failed, logout user
|
|
localStorage.removeItem('accessToken');
|
|
localStorage.removeItem('refreshToken');
|
|
window.location.href = '/login';
|
|
return Promise.reject(refreshError);
|
|
}
|
|
}
|
|
|
|
console.error('API Response Error:', error.response?.data || error.message);
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// Routes API
|
|
export const routesApi = {
|
|
getAll: () => api.get('/routes'),
|
|
getById: (id) => api.get(`/routes/${id}`),
|
|
create: (data) => api.post('/routes', data),
|
|
update: (id, data) => api.put(`/routes/${id}`, data),
|
|
delete: (id) => api.delete(`/routes/${id}`)
|
|
};
|
|
|
|
export const citiesApi = {
|
|
getAll: () => api.get('/cities'),
|
|
create: (data) => api.post('/cities', data),
|
|
update: (cityId, data) => api.put(`/cities/${cityId}`, data),
|
|
uploadAvatar: (cityId, file) => {
|
|
const formData = new FormData();
|
|
formData.append('avatar', file);
|
|
return api.post(`/cities/${cityId}/avatar`, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
}
|
|
};
|
|
|
|
// Route Stops API (привязка остановок к маршрутам)
|
|
export const routeStopsApi = {
|
|
getRouteStops: (routeId) => api.get(`/routes/${routeId}/stops`),
|
|
getAvailableStops: (routeId) => api.get(`/routes/${routeId}/available-stops`),
|
|
addStop: (routeId, data) => api.post(`/routes/${routeId}/stops`, data),
|
|
updateStop: (routeId, routeStopId, data) => api.put(`/routes/${routeId}/stops/${routeStopId}`, data),
|
|
removeStop: (routeId, routeStopId) => api.delete(`/routes/${routeId}/stops/${routeStopId}`),
|
|
reorderStops: (routeId, stops) => api.put(`/routes/${routeId}/stops-reorder`, { stops })
|
|
};
|
|
|
|
// Stops API
|
|
export const stopsApi = {
|
|
getAll: () => api.get('/stops'),
|
|
getById: (id) => api.get(`/stops/${id}`),
|
|
getNearby: (lat, lon, radius = 500) =>
|
|
api.get('/stops/nearby', { params: { lat, lon, radius } }),
|
|
create: (data) => api.post('/stops', data),
|
|
update: (id, data) => api.put(`/stops/${id}`, data),
|
|
delete: (id) => api.delete(`/stops/${id}`)
|
|
};
|
|
|
|
// ETA API
|
|
export const etaApi = {
|
|
calculate: (stopId, routeId) =>
|
|
api.post('/eta/calculate', { stopId, routeId }),
|
|
getStopEtas: (stopId) => api.get(`/eta/stop/${stopId}`)
|
|
};
|
|
|
|
// Sync API
|
|
export const syncApi = {
|
|
sync: (lastSync) => api.get('/sync', { params: { lastSync } }),
|
|
getStatus: () => api.get('/sync/status')
|
|
};
|
|
|
|
export const qualityApi = {
|
|
getReport: () => api.get('/quality')
|
|
};
|
|
|
|
export const scheduleCenterApi = {
|
|
getOverview: (date) => api.get('/schedule-center', { params: { date } })
|
|
};
|
|
|
|
// Schedules API (расписания)
|
|
export const schedulesApi = {
|
|
getRouteSchedules: (routeId) => api.get(`/routes/${routeId}/schedules`),
|
|
getTodaySchedule: (routeId, date) => api.get(`/routes/${routeId}/schedule/today`, { params: { date } }),
|
|
getById: (id) => api.get(`/schedules/${id}`),
|
|
create: (data) => api.post('/schedules', data),
|
|
update: (id, data) => api.put(`/schedules/${id}`, data),
|
|
delete: (id) => api.delete(`/schedules/${id}`),
|
|
copy: (routeId, fromDayType, toDayType, sourceScheduleId) => api.post(`/routes/${routeId}/schedules/copy`, { fromDayType, toDayType, sourceScheduleId })
|
|
};
|
|
|
|
export const scheduleImportApi = {
|
|
catalog: () => api.get('/schedule-import/catalog'),
|
|
inspect: (data) => api.post('/schedule-import/inspect', data),
|
|
preview: (data) => api.post('/schedule-import/preview', data),
|
|
apply: (id) => api.post(`/schedule-import/${id}/apply`),
|
|
history: () => api.get('/schedule-import/history'),
|
|
news: () => api.get('/schedule-import/news'),
|
|
refreshNews: () => api.post('/schedule-import/news/refresh'),
|
|
reviewNews: (data) => api.post('/schedule-import/news/review', data),
|
|
};
|
|
|
|
// Holidays API (праздники)
|
|
export const holidaysApi = {
|
|
getAll: (year) => api.get('/holidays', { params: { year } }),
|
|
getUpcoming: (limit) => api.get('/holidays/upcoming', { params: { limit } }),
|
|
getForYear: (year) => api.get(`/holidays/year/${year}`),
|
|
checkDate: (date) => api.get(`/holidays/check/${date}`),
|
|
create: (data) => api.post('/holidays', data),
|
|
update: (id, data) => api.put(`/holidays/${id}`, data),
|
|
delete: (id) => api.delete(`/holidays/${id}`)
|
|
};
|
|
|
|
// Alerts API (уведомления)
|
|
export const telegramImportApi = {
|
|
getAll: () => api.get('/telegram-import'),
|
|
sync: () => api.post('/telegram-import/sync'),
|
|
review: (id, data) => api.post(`/telegram-import/${id}/review`, data),
|
|
getStopAliases: () => api.get('/telegram-import/stop-aliases'),
|
|
createStopAlias: data => api.post('/telegram-import/stop-aliases', data),
|
|
updateStopAlias: (id, data) => api.put(`/telegram-import/stop-aliases/${id}`, data),
|
|
deleteStopAlias: id => api.delete(`/telegram-import/stop-aliases/${id}`)
|
|
};
|
|
|
|
export const alertsApi = {
|
|
getAll: (params) => api.get('/alerts', { params }),
|
|
getActive: (routeId) => api.get('/alerts/active', { params: { route_id: routeId } }),
|
|
getTemplates: () => api.get('/alerts/templates'),
|
|
getById: (id) => api.get(`/alerts/${id}`),
|
|
create: (data, idempotencyKey) => api.post('/alerts', data, {
|
|
headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined
|
|
}),
|
|
update: (id, data) => api.put(`/alerts/${id}`, data),
|
|
delete: (id) => api.delete(`/alerts/${id}`),
|
|
cleanup: () => api.post('/alerts/cleanup')
|
|
};
|
|
|
|
// Telemetry API (живые GPS-точки)
|
|
export const telemetryApi = {
|
|
ingest: (data) => api.post('/telemetry/ingest', data)
|
|
};
|
|
|
|
// Realtime snapshot API (fallback/snapshot для live данных)
|
|
export const realtimeApi = {
|
|
getRouteVehicles: (routeId, params) => api.get(`/realtime/routes/${routeId}/vehicles`, { params }),
|
|
getVehicle: (vehicleId, params) => api.get(`/realtime/vehicles/${vehicleId}`, { params })
|
|
};
|
|
|
|
export const boardDevicesApi = {
|
|
list: () => api.get('/board-devices/admin'),
|
|
routes: stopId => api.get('/board-devices/admin/routes', { params: { stopId } }),
|
|
claim: data => api.post('/board-devices/admin/claim', data),
|
|
update: (id, data) => api.put(`/board-devices/admin/${id}`, data),
|
|
revoke: id => api.post(`/board-devices/admin/${id}/revoke`),
|
|
};
|
|
|
|
export default api;
|