real back
This commit is contained in:
@@ -4,7 +4,7 @@ import { Elysia } from 'elysia';
|
||||
import { WebStandardAdapter } from 'elysia/adapter/web-standard';
|
||||
|
||||
type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
|
||||
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив';
|
||||
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Возвращен' | 'Архив';
|
||||
type Visibility = 'Публично' | 'После входа' | 'По роли';
|
||||
type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
|
||||
|
||||
@@ -28,6 +28,11 @@ type ContentItem = {
|
||||
mimeType?: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
moderatorComment?: string;
|
||||
reviewComment?: string;
|
||||
ratingAverage?: number;
|
||||
ratingCount?: number;
|
||||
myRating?: number;
|
||||
};
|
||||
|
||||
type MediaKind = NonNullable<ContentItem['mediaKind']>;
|
||||
@@ -40,6 +45,18 @@ type StoredUpload = {
|
||||
data: Buffer;
|
||||
};
|
||||
|
||||
type NormalizedContentPatch = Partial<ContentItem> & {
|
||||
title?: string;
|
||||
category?: string;
|
||||
lead?: string;
|
||||
body?: string;
|
||||
type?: ContentType;
|
||||
status?: ContentStatus;
|
||||
tags?: string[];
|
||||
moderatorComment?: string;
|
||||
reviewComment?: string;
|
||||
};
|
||||
|
||||
type UserProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -76,6 +93,28 @@ const serviceUrls: Record<string, string | undefined> = {
|
||||
const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия'];
|
||||
const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив'];
|
||||
const contentTypes: ContentType[] = ['news', 'article', 'video', 'audio', 'graphic', 'event'];
|
||||
const loginAliases: Record<string, string> = {
|
||||
'admin@dstu.ru': 'demo_admin',
|
||||
'editor@dstu.ru': 'demo_editor',
|
||||
'moderator@dstu.ru': 'demo_moderator',
|
||||
'manager@dstu.ru': 'demo_moderator',
|
||||
'user@dstu.ru': 'demo_user'
|
||||
};
|
||||
const statusAliases: Record<string, ContentStatus> = {
|
||||
draft: 'Черновик',
|
||||
moderation: 'На модерации',
|
||||
pending: 'На модерации',
|
||||
review: 'На проверке',
|
||||
published: 'Опубликовано',
|
||||
returned: 'Возвращен',
|
||||
archived: 'Архив',
|
||||
черновик: 'Черновик',
|
||||
'на модерации': 'На модерации',
|
||||
'на проверке': 'На проверке',
|
||||
опубликовано: 'Опубликовано',
|
||||
возвращен: 'Возвращен',
|
||||
архив: 'Архив'
|
||||
};
|
||||
|
||||
const demoUser: UserProfile = {
|
||||
id: 'demo-user-1',
|
||||
@@ -88,6 +127,7 @@ const demoUser: UserProfile = {
|
||||
const users: UserProfile[] = [
|
||||
demoUser,
|
||||
{ id: 'demo-user-2', name: 'Демо-редактор', login: 'demo_editor', roles: ['редактор'], subscriptions: ['Видео'] },
|
||||
{ id: 'demo-user-4', name: 'Демо-модератор', login: 'demo_moderator', roles: ['менеджер'], subscriptions: ['Мероприятия'] },
|
||||
{ id: 'demo-user-3', name: 'Демо-пользователь', login: 'demo_user', roles: ['пользователь'], subscriptions: ['Аудио'] }
|
||||
];
|
||||
|
||||
@@ -298,6 +338,97 @@ function searchContent(query: Record<string, string | undefined>) {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function normalizeLogin(value = '') {
|
||||
const login = value.trim().toLowerCase();
|
||||
return loginAliases[login] ?? login;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): ContentStatus | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
return statusAliases[value.trim().toLowerCase()] ?? (value as ContentStatus);
|
||||
}
|
||||
|
||||
function normalizeContentType(value: unknown): ContentType | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'research') return 'article';
|
||||
if (normalized === 'announcement') return 'news';
|
||||
if (contentTypes.includes(normalized as ContentType)) return normalized as ContentType;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeTags(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return parseTags(value);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeContentPatch(payload: Record<string, unknown>): NormalizedContentPatch {
|
||||
const nextType = normalizeContentType(payload.type);
|
||||
const nextStatus = normalizeStatus(payload.status);
|
||||
const nextTags = payload.tags === undefined ? undefined : normalizeTags(payload.tags);
|
||||
|
||||
return {
|
||||
title: typeof payload.title === 'string' ? payload.title.trim() : undefined,
|
||||
category: typeof payload.category === 'string' ? payload.category.trim() : undefined,
|
||||
lead: typeof payload.lead === 'string' ? payload.lead : typeof payload.excerpt === 'string' ? payload.excerpt : undefined,
|
||||
body: typeof payload.body === 'string' ? payload.body : typeof payload.content === 'string' ? payload.content : undefined,
|
||||
type: nextType,
|
||||
status: nextStatus,
|
||||
tags: nextTags,
|
||||
visibility: payload.visibility as Visibility | undefined,
|
||||
imageTone: typeof payload.imageTone === 'string' ? payload.imageTone : undefined,
|
||||
mediaUrl: typeof payload.mediaUrl === 'string' ? payload.mediaUrl : undefined,
|
||||
mediaKind: payload.mediaKind as ContentItem['mediaKind'] | undefined,
|
||||
mimeType: typeof payload.mimeType === 'string' ? payload.mimeType : undefined,
|
||||
fileName: typeof payload.fileName === 'string' ? payload.fileName : undefined,
|
||||
fileSize: typeof payload.fileSize === 'number' ? payload.fileSize : undefined,
|
||||
ratingAverage: typeof payload.ratingAverage === 'number' ? payload.ratingAverage : undefined,
|
||||
ratingCount: typeof payload.ratingCount === 'number' ? payload.ratingCount : undefined,
|
||||
myRating: typeof payload.myRating === 'number' ? payload.myRating : undefined,
|
||||
moderatorComment:
|
||||
typeof payload.moderatorComment === 'string'
|
||||
? payload.moderatorComment.trim()
|
||||
: typeof payload.reviewComment === 'string'
|
||||
? payload.reviewComment.trim()
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function listContent(query: Record<string, string | undefined>, user: UserProfile | null) {
|
||||
const requestedType = normalizeContentType(query.type);
|
||||
const requestedStatus = normalizeStatus(query.status);
|
||||
const requestedAuthor = query.authorId;
|
||||
const mine = query.mine === 'true';
|
||||
const limit = Number(query.limit ?? 0);
|
||||
const exclude = query.exclude;
|
||||
const term = (query.q ?? '').trim().toLowerCase();
|
||||
|
||||
const items = content
|
||||
.filter((item) => !exclude || item.id !== exclude)
|
||||
.filter((item) => !requestedType || item.type === requestedType)
|
||||
.filter((item) => !query.category || item.category === query.category)
|
||||
.filter((item) => !requestedStatus || item.status === requestedStatus)
|
||||
.filter((item) => !requestedAuthor || item.author === users.find((entry) => entry.id === requestedAuthor)?.name)
|
||||
.filter((item) => !mine || (user ? item.author === user.name : false))
|
||||
.filter((item) => !term || [item.title, item.lead, item.body, item.author, item.category, item.status, ...item.tags].join(' ').toLowerCase().includes(term))
|
||||
.sort((a, b) => {
|
||||
if (query.sort === 'popular') return b.views - a.views;
|
||||
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
|
||||
});
|
||||
|
||||
return limit > 0 ? items.slice(0, limit) : items;
|
||||
}
|
||||
|
||||
function isBlobLike(value: unknown): value is Blob & { name?: string; type?: string; size: number } {
|
||||
return typeof value === 'object' && value !== null && 'arrayBuffer' in value && typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function';
|
||||
}
|
||||
@@ -533,14 +664,15 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
||||
.get('/api/health', ({ set }) => ({ status: 'ok', service: 'gateway', requestId: set.headers['x-request-id'] }))
|
||||
.get('/api/services/health', async () => ({ items: await Promise.all(Object.entries(serviceUrls).map(([name, url]) => readServiceHealth(name, url))) }))
|
||||
.post('/api/auth/register', ({ body, set }) => {
|
||||
const payload = body as Partial<{ login: string; password: string; name: string }>;
|
||||
if (!payload.login || !payload.password || payload.password.length < 8) {
|
||||
const payload = body as Partial<{ login: string; email: string; password: string; name: string }>;
|
||||
const login = normalizeLogin(payload.login ?? payload.email ?? '');
|
||||
if (!login || !payload.password || payload.password.length < 8) {
|
||||
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов');
|
||||
}
|
||||
const user: UserProfile = {
|
||||
id: `demo-user-${Date.now()}`,
|
||||
name: payload.name?.trim() || 'Демо-пользователь',
|
||||
login: payload.login,
|
||||
login,
|
||||
roles: ['пользователь'],
|
||||
subscriptions: []
|
||||
};
|
||||
@@ -551,11 +683,12 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
||||
return { token, user };
|
||||
})
|
||||
.post('/api/auth/login', ({ body, set }) => {
|
||||
const payload = body as Partial<{ login: string; password: string }>;
|
||||
if (!payload.login || !payload.password) {
|
||||
const payload = body as Partial<{ login: string; email: string; password: string }>;
|
||||
const login = normalizeLogin(payload.login ?? payload.email ?? '');
|
||||
if (!login || !payload.password) {
|
||||
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль');
|
||||
}
|
||||
const user = users.find((item) => item.login === payload.login) ?? demoUser;
|
||||
const user = users.find((item) => item.login === login) ?? demoUser;
|
||||
const token = `demo-token-${crypto.randomUUID()}`;
|
||||
tokens.set(token, user);
|
||||
return { token, user };
|
||||
@@ -585,7 +718,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
||||
}
|
||||
return { ok: true };
|
||||
})
|
||||
.get('/api/content', () => ({ items: content }))
|
||||
.get('/api/content', ({ query, request }) => ({ items: listContent(query as Record<string, string | undefined>, readUser(request)) }))
|
||||
.get('/api/content/:id', ({ params, set }) => {
|
||||
const item = content.find((entry) => entry.id === params.id);
|
||||
if (!item) {
|
||||
@@ -602,7 +735,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
||||
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
|
||||
return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора');
|
||||
}
|
||||
const payload = body as Partial<ContentItem>;
|
||||
const payload = normalizeContentPatch(body as Record<string, unknown>);
|
||||
if (!payload.title || !payload.category || !payload.type) {
|
||||
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала');
|
||||
}
|
||||
@@ -617,14 +750,19 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
||||
author: auth.user.name,
|
||||
publishedAt: new Date().toISOString().slice(0, 10),
|
||||
visibility: payload.visibility ?? 'После входа',
|
||||
status: 'Черновик',
|
||||
status: payload.status ?? 'Черновик',
|
||||
views: 0,
|
||||
imageTone: payload.imageTone ?? 'from-university-800 via-slate-700 to-sky-300',
|
||||
mediaUrl: payload.mediaUrl,
|
||||
mediaKind: payload.mediaKind,
|
||||
mimeType: payload.mimeType,
|
||||
fileName: payload.fileName,
|
||||
fileSize: payload.fileSize
|
||||
fileSize: payload.fileSize,
|
||||
moderatorComment: payload.moderatorComment,
|
||||
reviewComment: payload.reviewComment,
|
||||
ratingAverage: 0,
|
||||
ratingCount: 0,
|
||||
myRating: 0
|
||||
};
|
||||
content = [item, ...content];
|
||||
set.status = 201;
|
||||
@@ -639,7 +777,24 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
||||
if (index === -1) {
|
||||
return fail(set, 404, 'NOT_FOUND', 'Материал не найден');
|
||||
}
|
||||
content[index] = { ...content[index], ...(body as Partial<ContentItem>) };
|
||||
const payload = normalizeContentPatch(body as Record<string, unknown>);
|
||||
const nextRating = typeof payload.ratingAverage === 'number' ? payload.ratingAverage : undefined;
|
||||
const nextUserRating = typeof body === 'object' && body !== null && typeof (body as Record<string, unknown>).rating === 'number'
|
||||
? Number((body as Record<string, unknown>).rating)
|
||||
: undefined;
|
||||
|
||||
content[index] = {
|
||||
...content[index],
|
||||
...payload,
|
||||
lead: payload.lead ?? content[index].lead,
|
||||
body: payload.body ?? content[index].body,
|
||||
tags: payload.tags ?? content[index].tags,
|
||||
status: payload.status ?? content[index].status,
|
||||
ratingAverage: nextUserRating ?? nextRating ?? content[index].ratingAverage,
|
||||
ratingCount: nextUserRating ? Math.max(Number(content[index].ratingCount ?? 0) + 1, 1) : content[index].ratingCount,
|
||||
myRating: nextUserRating ?? content[index].myRating,
|
||||
reviewComment: payload.moderatorComment ?? payload.reviewComment ?? content[index].reviewComment
|
||||
};
|
||||
return { item: content[index] };
|
||||
})
|
||||
.delete('/api/content/:id', ({ request, params, set }) => {
|
||||
@@ -770,11 +925,13 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
||||
return { item };
|
||||
})
|
||||
.get('/api/analytics/summary', ({ request, set }) => {
|
||||
const auth = requireRole(request, set, 'администратор');
|
||||
const auth = requireUser(request, set);
|
||||
if (!auth.user) {
|
||||
return auth.response;
|
||||
}
|
||||
return {
|
||||
materials: content.filter((item) => item.author === auth.user?.name).length,
|
||||
comments: comments.length,
|
||||
totalViews: content.reduce((sum, item) => sum + item.views, 0),
|
||||
subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0),
|
||||
activeUsers: users.length,
|
||||
@@ -788,8 +945,18 @@ const app = new Elysia({ adapter: WebStandardAdapter })
|
||||
}
|
||||
return {
|
||||
users: users.length,
|
||||
content: content.length,
|
||||
moderationQueue: content.filter((item) => item.status !== 'Опубликовано').length,
|
||||
materials: content.length,
|
||||
views: content.reduce((sum, item) => sum + item.views, 0),
|
||||
pending: content.filter((item) => item.status !== 'Опубликовано').length,
|
||||
viewsByDay: [
|
||||
{ label: 'Пн', value: 120 },
|
||||
{ label: 'Вт', value: 180 },
|
||||
{ label: 'Ср', value: 160 },
|
||||
{ label: 'Чт', value: 220 },
|
||||
{ label: 'Пт', value: 260 },
|
||||
{ label: 'Сб', value: 140 },
|
||||
{ label: 'Вс', value: 110 }
|
||||
],
|
||||
roles: ['администратор', 'редактор', 'менеджер', 'пользователь']
|
||||
};
|
||||
})
|
||||
|
||||
@@ -5,22 +5,29 @@ Frontend MVP информационной системы управления м
|
||||
|
||||
## Запуск
|
||||
|
||||
Требуются Node.js 22+ и Corepack.
|
||||
Требуются Node.js 22+ и npm.
|
||||
|
||||
Из корня монорепозитория:
|
||||
|
||||
```bash
|
||||
corepack prepare pnpm@11.0.0 --activate
|
||||
corepack pnpm install
|
||||
corepack pnpm dev
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Откройте `http://localhost:5173`.
|
||||
Для запуска только frontend:
|
||||
|
||||
```bash
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
Откройте `http://localhost:5173`. В dev-режиме Vite проксирует `/api` на `http://localhost:3000`.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
corepack pnpm lint
|
||||
corepack pnpm test
|
||||
corepack pnpm build
|
||||
npm --workspace @fable/web run lint
|
||||
npm --workspace @fable/web run test
|
||||
npm --workspace @fable/web run build
|
||||
```
|
||||
|
||||
## Структура
|
||||
@@ -56,7 +63,7 @@ src/
|
||||
|
||||
## Тестовые роли
|
||||
|
||||
Пароль может быть любым от 6 символов.
|
||||
Основной demo-вход: `demo_admin` / `demo_password`.
|
||||
|
||||
| Роль | Учётная запись |
|
||||
| --- | --- |
|
||||
@@ -77,7 +84,7 @@ src/
|
||||
- `/admin/users`, `/admin/stats`, `/admin/audit-log`;
|
||||
- `POST /uploads`.
|
||||
|
||||
Адрес API задаётся через `VITE_API_URL`. Пока интерфейс использует локальные mock-данные.
|
||||
Адрес API можно переопределить через `VITE_API_URL`. По умолчанию интерфейс работает с `/api` и ожидает gateway на `localhost:3000`.
|
||||
|
||||
## Безопасность зависимостей
|
||||
|
||||
@@ -92,7 +99,6 @@ Lockfile необходимо хранить в репозитории.
|
||||
|
||||
## Ограничения MVP
|
||||
|
||||
- данные не сохраняются после обновления страницы;
|
||||
- загрузка файлов и комментарии представлены интерфейсом без backend;
|
||||
- данные backend по-прежнему демонстрационные и в основном живут в in-memory store;
|
||||
- внешние изображения загружаются с Unsplash;
|
||||
- график администратора демонстрационный.
|
||||
|
||||
@@ -2,16 +2,56 @@ import { create } from "zustand";
|
||||
import { authApi } from "../../shared/api/endpoints";
|
||||
import { tokenStorage } from "../../shared/api/client";
|
||||
|
||||
const roleMap = {
|
||||
admin: "admin",
|
||||
administrator: "admin",
|
||||
user: "user",
|
||||
editor: "editor",
|
||||
moderator: "moderator",
|
||||
manager: "moderator",
|
||||
администратор: "admin",
|
||||
редактор: "editor",
|
||||
менеджер: "moderator",
|
||||
пользователь: "user",
|
||||
};
|
||||
|
||||
function pickRole(user = {}) {
|
||||
const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean);
|
||||
const normalizedRoles = roles.map((role) => roleMap[String(role).toLowerCase()] ?? role);
|
||||
|
||||
if (normalizedRoles.includes("admin")) return "admin";
|
||||
if (normalizedRoles.includes("moderator")) return "moderator";
|
||||
if (normalizedRoles.includes("editor")) return "editor";
|
||||
return "user";
|
||||
}
|
||||
|
||||
function normalizeUser(user) {
|
||||
if (!user) return null;
|
||||
|
||||
const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean);
|
||||
const role = pickRole(user);
|
||||
const login = user.login ?? user.username ?? "";
|
||||
|
||||
return {
|
||||
...user,
|
||||
login,
|
||||
email: user.email ?? (login && login.includes("@") ? login : ""),
|
||||
roles,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
function readStoredUser() {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem("user") ?? "null");
|
||||
return normalizeUser(JSON.parse(sessionStorage.getItem("user") ?? "null"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistUser(user) {
|
||||
if (user) sessionStorage.setItem("user", JSON.stringify(user));
|
||||
const normalizedUser = normalizeUser(user);
|
||||
if (normalizedUser) sessionStorage.setItem("user", JSON.stringify(normalizedUser));
|
||||
else sessionStorage.removeItem("user");
|
||||
}
|
||||
|
||||
@@ -20,7 +60,7 @@ function getToken(payload) {
|
||||
}
|
||||
|
||||
function getUser(payload) {
|
||||
return payload?.user ?? payload?.profile ?? payload;
|
||||
return normalizeUser(payload?.user ?? payload?.profile ?? payload);
|
||||
}
|
||||
|
||||
export const useSession = create((set, get) => ({
|
||||
@@ -37,12 +77,13 @@ export const useSession = create((set, get) => ({
|
||||
return user;
|
||||
},
|
||||
|
||||
login: async ({ email, password }) => {
|
||||
const response = await authApi.login({ email, password });
|
||||
login: async ({ identifier, email, login, password }) => {
|
||||
const credential = identifier ?? login ?? email;
|
||||
const response = await authApi.login({ login: credential, email: credential, password });
|
||||
const token = getToken(response);
|
||||
tokenStorage.set(token);
|
||||
|
||||
const user = response.user ?? (await authApi.me());
|
||||
const user = normalizeUser(response.user ?? (await authApi.me()));
|
||||
set({ user });
|
||||
persistUser(user);
|
||||
return user;
|
||||
@@ -53,7 +94,7 @@ export const useSession = create((set, get) => ({
|
||||
|
||||
set({ initializing: true });
|
||||
try {
|
||||
const user = await authApi.me();
|
||||
const user = normalizeUser(await authApi.me());
|
||||
set({ user, initializing: false });
|
||||
persistUser(user);
|
||||
return user;
|
||||
@@ -87,7 +128,15 @@ export const useSession = create((set, get) => ({
|
||||
return user;
|
||||
},
|
||||
|
||||
switchRole: () => get().user,
|
||||
switchRole: (nextRole) => {
|
||||
const current = get().user;
|
||||
if (!current) return null;
|
||||
|
||||
const user = normalizeUser({ ...current, role: nextRole });
|
||||
set({ user });
|
||||
persistUser(user);
|
||||
return user;
|
||||
},
|
||||
}));
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
|
||||
@@ -7,12 +7,32 @@ import { Badge } from "../shared/ui/Badge";
|
||||
import { Button } from "../shared/ui/Button";
|
||||
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
|
||||
|
||||
function normalizeEvent(event) {
|
||||
const rawDate = event.date ?? event.startsAt ?? event.publishedAt ?? "";
|
||||
const parsedDate = rawDate ? new Date(rawDate) : null;
|
||||
const day = parsedDate && !Number.isNaN(parsedDate.getTime())
|
||||
? parsedDate.toLocaleDateString("ru-RU", { day: "numeric" })
|
||||
: String(rawDate).split(" ")[0] ?? "--";
|
||||
const month = parsedDate && !Number.isNaN(parsedDate.getTime())
|
||||
? parsedDate.toLocaleDateString("ru-RU", { month: "short" })
|
||||
: String(rawDate).split(" ")[1] ?? "";
|
||||
|
||||
return {
|
||||
...event,
|
||||
day,
|
||||
month,
|
||||
description: event.description ?? event.lead ?? event.body ?? "Описание события появится после загрузки данных.",
|
||||
time: event.time ?? event.startsAt ?? "Время уточняется",
|
||||
place: event.place ?? event.location ?? "Место уточняется",
|
||||
};
|
||||
}
|
||||
|
||||
export function EventsPage() {
|
||||
const { data: eventsPayload, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.events(),
|
||||
queryFn: directoriesApi.events,
|
||||
});
|
||||
const pageEvents = toList(eventsPayload);
|
||||
const pageEvents = toList(eventsPayload).map(normalizeEvent);
|
||||
|
||||
return (
|
||||
<div className="page-shell py-10">
|
||||
@@ -51,10 +71,10 @@ export function EventsPage() {
|
||||
className="group grid gap-5 py-8 md:grid-cols-[minmax(7rem,1fr)_minmax(0,5fr)_auto] md:items-center"
|
||||
>
|
||||
<div className="flex items-baseline gap-2 md:block">
|
||||
<span className="font-serif text-5xl text-primary">{event.date.split(" ")[0]}</span>
|
||||
<span className="text-sm uppercase tracking-widest text-muted">
|
||||
{event.date.split(" ")[1]}
|
||||
</span>
|
||||
<span className="font-serif text-5xl text-primary">{event.day}</span>
|
||||
<span className="text-sm uppercase tracking-widest text-muted">
|
||||
{event.month}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Badge tone={index === 0 ? "accent" : "neutral"}>{event.category}</Badge>
|
||||
|
||||
@@ -179,8 +179,9 @@ export function HomePage() {
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-accent">Навигация по темам</p>
|
||||
<div className="mt-5 grid border-l border-t border-line sm:grid-cols-2 lg:grid-cols-4">
|
||||
{pageCategories.map((category, index) => {
|
||||
const name = category.name ?? category.title ?? category.label;
|
||||
const value = category.slug ?? category.id ?? name;
|
||||
const name = typeof category === "string" ? category : category.name ?? category.title ?? category.label;
|
||||
const value = typeof category === "string" ? category : category.slug ?? category.id ?? name;
|
||||
const description = typeof category === "string" ? "Материалы этой категории доступны в едином каталоге." : category.description;
|
||||
return (
|
||||
<Link
|
||||
key={category.id ?? value}
|
||||
@@ -192,7 +193,7 @@ export function HomePage() {
|
||||
</span>
|
||||
<h3 className="mt-12 font-serif text-2xl">{name}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-muted transition group-hover:text-white/65">
|
||||
{category.description}
|
||||
{description}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from "../shared/ui/Button";
|
||||
import { Input } from "../shared/ui/Field";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.email("Введите корректный адрес"),
|
||||
identifier: z.string().min(3, "Введите логин или почту"),
|
||||
password: z.string().min(6, "Минимум 6 символов"),
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export function LoginPage() {
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: "editor@dstu.ru", password: "password" },
|
||||
defaultValues: { identifier: "demo_admin", password: "demo_password" },
|
||||
});
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
@@ -52,14 +52,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
<h1 className="mt-6 font-serif text-4xl">Вход в систему</h1>
|
||||
<p className="mt-3 leading-7 text-muted">
|
||||
Используйте тестовый адрес роли или учетную запись backend.
|
||||
Используйте демо-логин backend или тестовую почту роли.
|
||||
</p>
|
||||
<form className="mt-8 grid gap-5" noValidate onSubmit={handleSubmit(onSubmit)}>
|
||||
<Input
|
||||
label="Корпоративная почта"
|
||||
type="email"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
label="Логин или почта"
|
||||
error={errors.identifier?.message}
|
||||
{...register("identifier")}
|
||||
/>
|
||||
<Input
|
||||
label="Пароль"
|
||||
@@ -72,8 +71,8 @@ export function LoginPage() {
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-7 rounded-lg border border-line bg-surface p-4 text-xs leading-6 text-muted">
|
||||
<b className="text-ink">Тестовые роли:</b> user@dstu.ru, editor@dstu.ru,
|
||||
moderator@dstu.ru, admin@dstu.ru
|
||||
<b className="text-ink">Демо-вход:</b> demo_admin / demo_password. Также поддерживаются
|
||||
user@dstu.ru, editor@dstu.ru, moderator@dstu.ru и admin@dstu.ru.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,15 +12,15 @@ describe("LoginPage", () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const email = screen.getByLabelText("Корпоративная почта");
|
||||
const email = screen.getByLabelText("Логин или почта");
|
||||
const password = screen.getByLabelText("Пароль");
|
||||
await user.clear(email);
|
||||
await user.type(email, "wrong");
|
||||
await user.type(email, "ab");
|
||||
await user.clear(password);
|
||||
await user.type(password, "123");
|
||||
await user.click(screen.getByRole("button", { name: "Войти" }));
|
||||
|
||||
expect(await screen.findByText("Введите корректный адрес")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Введите логин или почту")).toBeInTheDocument();
|
||||
expect(screen.getByText("Минимум 6 символов")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,17 @@ const materialTypes = [
|
||||
];
|
||||
|
||||
function normalizeCategories(payload) {
|
||||
return toList(payload).map((item) => ({
|
||||
id: item.id ?? item.slug ?? item.name,
|
||||
name: item.name ?? item.title ?? item.label,
|
||||
value: item.slug ?? item.id ?? item.name,
|
||||
}));
|
||||
return toList(payload).map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return { id: item, name: item, value: item };
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id ?? item.slug ?? item.name,
|
||||
name: item.name ?? item.title ?? item.label,
|
||||
value: item.slug ?? item.id ?? item.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function MaterialsPage() {
|
||||
|
||||
@@ -13,6 +13,19 @@ function number(value) {
|
||||
return Number(value ?? 0).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function normalizeUsers(payload) {
|
||||
return toList(payload).map((user) => {
|
||||
const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean);
|
||||
const primaryRole = roles[0] ?? "user";
|
||||
|
||||
return {
|
||||
...user,
|
||||
email: user.email ?? user.login ?? "-",
|
||||
role: user.role ?? primaryRole,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function pickChart(summary) {
|
||||
const values = summary.viewsByDay ?? summary.weeklyViews ?? summary.chart ?? [];
|
||||
if (!Array.isArray(values)) return [];
|
||||
@@ -34,7 +47,7 @@ export function AdminPage() {
|
||||
});
|
||||
|
||||
const summary = toEntity(dashboardQuery.data) ?? {};
|
||||
const users = toList(usersQuery.data);
|
||||
const users = normalizeUsers(usersQuery.data);
|
||||
const popular = normalizeContentList(popularQuery.data);
|
||||
const chart = pickChart(summary);
|
||||
const maxChart = Math.max(...chart.map((item) => Number(item.value ?? item.views ?? 0)), 1);
|
||||
|
||||
@@ -89,7 +89,7 @@ export function CabinetHomePage() {
|
||||
<span className={`mt-1.5 size-2 rounded-full ${item.read || item.isRead ? "bg-line" : "bg-accent"}`} />
|
||||
<div>
|
||||
<h3 className="font-semibold">{item.title ?? "Уведомление"}</h3>
|
||||
<p className="mt-1 text-sm text-muted">{item.text ?? item.message ?? item.body}</p>
|
||||
<p className="mt-1 text-sm text-muted">{item.text ?? item.message ?? item.description ?? item.body}</p>
|
||||
<p className="mt-2 text-xs text-muted">{formatDate(item.createdAt ?? item.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,11 +36,17 @@ function formatFileSize(size) {
|
||||
}
|
||||
|
||||
function normalizeCategories(payload) {
|
||||
return toList(payload).map((item) => ({
|
||||
id: item.id ?? item.slug ?? item.name,
|
||||
label: item.name ?? item.title ?? item.label,
|
||||
value: item.slug ?? item.id ?? item.name,
|
||||
}));
|
||||
return toList(payload).map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return { id: item, label: item, value: item };
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id ?? item.slug ?? item.name,
|
||||
label: item.name ?? item.title ?? item.label,
|
||||
value: item.slug ?? item.id ?? item.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function makePayload(data, photos, status) {
|
||||
|
||||
@@ -112,7 +112,7 @@ export function ProfilePage() {
|
||||
|
||||
<div className="mt-7 grid gap-5 sm:grid-cols-2">
|
||||
<Input name="name" label="Имя и фамилия" defaultValue={user.name ?? ""} disabled />
|
||||
<Input label="Электронная почта" defaultValue={user.email ?? ""} disabled />
|
||||
<Input label="Электронная почта" defaultValue={user.email ?? user.login ?? ""} disabled />
|
||||
<Input name="department" label="Подразделение" defaultValue={user.department ?? ""} disabled />
|
||||
<Input name="phone" label="Телефон" defaultValue={user.phone ?? ""} disabled />
|
||||
<Textarea
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000/api";
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL ?? "/api";
|
||||
|
||||
export const tokenStorage = {
|
||||
get: () => sessionStorage.getItem("accessToken"),
|
||||
@@ -32,8 +32,8 @@ api.interceptors.response.use(
|
||||
|
||||
return Promise.reject({
|
||||
status: error.response?.status ?? 0,
|
||||
message: error.response?.data?.message ?? "Не удалось выполнить запрос",
|
||||
fieldErrors: error.response?.data?.errors ?? error.response?.data?.fieldErrors,
|
||||
message: error.response?.data?.error?.message ?? error.response?.data?.message ?? "Не удалось выполнить запрос",
|
||||
fieldErrors: error.response?.data?.error?.errors ?? error.response?.data?.errors ?? error.response?.data?.fieldErrors,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,6 +8,9 @@ const labels = {
|
||||
published: "Опубликован",
|
||||
returned: "Возвращен",
|
||||
archived: "В архиве",
|
||||
Опубликовано: "Опубликовано",
|
||||
Возвращен: "Возвращен",
|
||||
Архив: "В архиве",
|
||||
};
|
||||
|
||||
const tones = {
|
||||
@@ -22,7 +25,9 @@ const tones = {
|
||||
"На модерации": "warning",
|
||||
Одобрен: "success",
|
||||
Опубликован: "success",
|
||||
Опубликовано: "success",
|
||||
Возвращен: "danger",
|
||||
Архив: "neutral",
|
||||
"В архиве": "neutral",
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,16 @@ export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/health": {
|
||||
target: "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
||||
Reference in New Issue
Block a user