rev 4
This commit is contained in:
@@ -83,55 +83,51 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
@include typography.font-body-14(600);
|
||||
color: variables.$text-primary;
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.itemHeadline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemMessage {
|
||||
@include typography.font-caption-m;
|
||||
color: variables.$text-secondary;
|
||||
margin-top: 2px;
|
||||
.itemTitle {
|
||||
@include typography.font-body-14(600);
|
||||
color: variables.$text-primary;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.itemTitleText {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
.itemStatusBadge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.itemTime {
|
||||
@include typography.font-caption-m;
|
||||
color: variables.$text-tertiary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.itemMessage {
|
||||
@include typography.font-caption-m;
|
||||
color: variables.$text-secondary;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.statusRunning {
|
||||
background-color: hsl(262, 50%, 94%);
|
||||
color: hsl(262, 72%, 45%);
|
||||
}
|
||||
|
||||
.statusDone {
|
||||
background-color: hsl(150, 30%, 92%);
|
||||
color: hsl(150, 50%, 30%);
|
||||
}
|
||||
|
||||
.statusFailed {
|
||||
background-color: hsl(0, 80%, 95%);
|
||||
color: hsl(0, 65%, 40%);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
|
||||
@@ -10,13 +10,16 @@ import { FunctionComponent, useCallback, useEffect, useRef } from "react"
|
||||
import { useDispatch } from "react-redux"
|
||||
|
||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||
import { formatRelativeTime } from "@shared/lib/dates"
|
||||
import { formatNotificationRelativeTime } from "@shared/lib/dates"
|
||||
import { API_URL } from "@shared/lib/constants"
|
||||
import {
|
||||
markAllRead,
|
||||
markRead,
|
||||
NotificationItem,
|
||||
} from "@shared/store/notifications"
|
||||
import { Badge } from "@shared/ui"
|
||||
|
||||
import { getNotificationPresentation } from "./presentation"
|
||||
|
||||
const apiBase = API_URL || "http://localhost:8000"
|
||||
|
||||
@@ -27,34 +30,6 @@ function authHeaders(): HeadersInit {
|
||||
|
||||
import styles from "./NotificationPopup.module.scss"
|
||||
|
||||
const JOB_TYPE_LABELS: Record<string, string> = {
|
||||
MEDIA_PROBE: "Анализ медиа",
|
||||
SILENCE_REMOVE: "Удаление тишины",
|
||||
MEDIA_CONVERT: "Конвертация",
|
||||
TRANSCRIPTION_GENERATE: "Транскрипция",
|
||||
CAPTIONS_GENERATE: "Генерация субтитров",
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Ожидание",
|
||||
RUNNING: "Выполняется",
|
||||
DONE: "Завершено",
|
||||
FAILED: "Ошибка",
|
||||
}
|
||||
|
||||
function getStatusClass(status: string | null): string {
|
||||
switch (status) {
|
||||
case "RUNNING":
|
||||
return styles.statusRunning
|
||||
case "DONE":
|
||||
return styles.statusDone
|
||||
case "FAILED":
|
||||
return styles.statusFailed
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
|
||||
onClose,
|
||||
anchorRef,
|
||||
@@ -120,56 +95,59 @@ export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>Нет уведомлений</div>
|
||||
) : (
|
||||
items.map((item, idx) => (
|
||||
<div
|
||||
key={item.notification_id || `${item.job_id}-${idx}`}
|
||||
className={cs(styles.item, {
|
||||
[styles.itemUnread]: !item.is_read,
|
||||
})}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.itemTitle}>
|
||||
<span>
|
||||
{item.job_type
|
||||
? (JOB_TYPE_LABELS[item.job_type] ||
|
||||
item.title)
|
||||
: item.title}
|
||||
</span>
|
||||
{item.status && (
|
||||
<span
|
||||
className={cs(
|
||||
styles.statusBadge,
|
||||
getStatusClass(item.status),
|
||||
)}
|
||||
>
|
||||
{STATUS_LABELS[item.status] ||
|
||||
item.status}
|
||||
items.map((item, idx) => {
|
||||
const presentation = getNotificationPresentation(item)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.notification_id || `${item.job_id}-${idx}`}
|
||||
className={cs(styles.item, {
|
||||
[styles.itemUnread]: !item.is_read,
|
||||
})}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.itemHeadline}>
|
||||
<div className={styles.itemTitle}>
|
||||
<span className={styles.itemTitleText}>
|
||||
{presentation.title}
|
||||
</span>
|
||||
</div>
|
||||
{presentation.statusText &&
|
||||
presentation.statusVariant && (
|
||||
<Badge
|
||||
variant={presentation.statusVariant}
|
||||
className={styles.itemStatusBadge}
|
||||
>
|
||||
{presentation.statusText}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className={styles.itemTime}>
|
||||
{formatNotificationRelativeTime(item.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.message && (
|
||||
<div className={styles.itemMessage}>
|
||||
{item.message}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "RUNNING" &&
|
||||
item.progress_pct != null && (
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{
|
||||
width: `${item.progress_pct}%`,
|
||||
}}
|
||||
/>
|
||||
{presentation.detailText && (
|
||||
<div className={styles.itemMessage}>
|
||||
{presentation.detailText}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.itemMeta}>
|
||||
{formatRelativeTime(item.created_at)}
|
||||
{item.status === "RUNNING" &&
|
||||
item.progress_pct != null && (
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{
|
||||
width: `${item.progress_pct}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { NotificationItem } from "@shared/store/notifications"
|
||||
|
||||
import { getNotificationPresentation } from "./presentation"
|
||||
|
||||
function buildNotification(
|
||||
overrides: Partial<NotificationItem> = {},
|
||||
): NotificationItem {
|
||||
return {
|
||||
event: "task_update",
|
||||
notification_id: "notification-1",
|
||||
job_id: "job-1",
|
||||
project_id: "project-1",
|
||||
job_type: "TRANSCRIPTION_GENERATE",
|
||||
status: "DONE",
|
||||
progress_pct: 100,
|
||||
message: "Завершено",
|
||||
title: "Задача завершена",
|
||||
created_at: "2026-04-05T12:00:00.000Z",
|
||||
is_read: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("getNotificationPresentation", () => {
|
||||
test("uses the task type as the notification title and moves status into secondary text", () => {
|
||||
expect(getNotificationPresentation(buildNotification())).toEqual({
|
||||
title: "Транскрипция",
|
||||
statusText: "Завершено",
|
||||
statusVariant: "success",
|
||||
detailText: null,
|
||||
})
|
||||
})
|
||||
|
||||
test("maps silence apply notifications to the operation title instead of backend status title", () => {
|
||||
expect(
|
||||
getNotificationPresentation(
|
||||
buildNotification({
|
||||
job_type: "SILENCE_APPLY",
|
||||
title: "Задача завершена",
|
||||
message: "Завершено",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: "Применение вырезок",
|
||||
statusText: "Завершено",
|
||||
statusVariant: "success",
|
||||
detailText: null,
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps detailed progress text when it carries more information than the status", () => {
|
||||
expect(
|
||||
getNotificationPresentation(
|
||||
buildNotification({
|
||||
status: "RUNNING",
|
||||
message: "Транскрибирование (whisper)",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: "Транскрипция",
|
||||
statusText: "Выполняется",
|
||||
statusVariant: "info",
|
||||
detailText: "Транскрибирование (whisper)",
|
||||
})
|
||||
})
|
||||
|
||||
test("maps failed notifications to a danger badge", () => {
|
||||
expect(
|
||||
getNotificationPresentation(
|
||||
buildNotification({
|
||||
status: "FAILED",
|
||||
message: "Ошибка",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: "Транскрипция",
|
||||
statusText: "Ошибка",
|
||||
statusVariant: "danger",
|
||||
detailText: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { NotificationItem } from "@shared/store/notifications"
|
||||
import type { BadgeVariant } from "@shared/ui/Badge/Badge.d"
|
||||
|
||||
const JOB_TYPE_LABELS: Record<string, string> = {
|
||||
MEDIA_PROBE: "Анализ медиа",
|
||||
SILENCE_REMOVE: "Удаление тишины",
|
||||
SILENCE_DETECT: "Анализ тишины",
|
||||
SILENCE_APPLY: "Применение вырезок",
|
||||
MEDIA_CONVERT: "Конвертация",
|
||||
TRANSCRIPTION_GENERATE: "Транскрипция",
|
||||
CAPTIONS_GENERATE: "Генерация субтитров",
|
||||
FRAME_EXTRACT: "Извлечение кадров",
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Ожидание",
|
||||
RUNNING: "Выполняется",
|
||||
DONE: "Завершено",
|
||||
FAILED: "Ошибка",
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<string, BadgeVariant> = {
|
||||
PENDING: "secondary",
|
||||
RUNNING: "info",
|
||||
DONE: "success",
|
||||
FAILED: "danger",
|
||||
}
|
||||
|
||||
export interface NotificationPresentation {
|
||||
title: string
|
||||
statusText: string | null
|
||||
statusVariant: BadgeVariant | null
|
||||
detailText: string | null
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
export function getNotificationPresentation(
|
||||
item: NotificationItem,
|
||||
): NotificationPresentation {
|
||||
const statusText = item.status ? (STATUS_LABELS[item.status] ?? item.status) : null
|
||||
const statusVariant = item.status ? (STATUS_VARIANTS[item.status] ?? null) : null
|
||||
const rawTitle = normalizeText(item.title)
|
||||
const rawMessage = normalizeText(item.message)
|
||||
const title = item.job_type
|
||||
? (JOB_TYPE_LABELS[item.job_type] ?? rawTitle ?? "Уведомление")
|
||||
: (rawTitle ?? "Уведомление")
|
||||
|
||||
return {
|
||||
title,
|
||||
statusText,
|
||||
statusVariant,
|
||||
detailText:
|
||||
rawMessage && rawMessage !== statusText && rawMessage !== rawTitle
|
||||
? rawMessage
|
||||
: null,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user