С фронтенда убрали возможность несколько чатов вести

This commit is contained in:
mi
2026-07-21 14:07:24 +03:00
parent 0d7f7a819f
commit 9cfc157124
5 changed files with 29 additions and 68 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
В разработку: В разработку:
1. Создание нового диалога через UI ~~1. MVP frontend: один чат с компанией без истории диалогов; пункт «Чат» открывает текущий активный диалог или создаёт его при отсутствии~~
~~2. При создании пользователя номер телефона копировать в профиль Russian_Phone~~ ~~2. При создании пользователя номер телефона копировать в профиль Russian_Phone~~
~~3. Убрать хеширование устройства клиента в devise_json - хочу видеть его параметры.~~ ~~3. Убрать хеширование устройства клиента в devise_json - хочу видеть его параметры.~~
4. Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс. ~~4. Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс.~~
5. Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе ~~5. Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе~~
6. Кнопка "Войти" для авторизации 6. Кнопка "Войти" для авторизации
7. Если неавторизованный пользователь вводит сообщение, после отправки идет на регистрацию, после окончания регистрации его сообщение пропадает. Надо чтобы сохранялось и отправлялось (по аналогии с нажатием на кнопку из раздела "Популярные вопросы") 7. Если неавторизованный пользователь вводит сообщение, после отправки идет на регистрацию, после окончания регистрации его сообщение пропадает. Надо чтобы сохранялось и отправлялось (по аналогии с нажатием на кнопку из раздела "Популярные вопросы")
~~8. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession)~~ ~~8. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession)~~
@@ -27,7 +27,6 @@ export default function ChatScreen() {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [creating, setCreating] = useState(false);
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config }); const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" }); const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" }); const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
@@ -106,15 +105,6 @@ export default function ChatScreen() {
} catch (reason) { setError(reason); } } catch (reason) { setError(reason); }
}; };
const createDialog = async () => {
setCreating(true); setError(undefined);
try {
const created = await dialogApi.create(crypto.randomUUID());
router.replace(`/dialogs/${created.dialog_id}`);
} catch (reason) { setError(reason); }
finally { setCreating(false); }
};
const closed = dialog.data?.status === "closed"; const closed = dialog.data?.status === "closed";
return <ScrollView contentContainerStyle={styles.page}> return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} /> <Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
@@ -134,9 +124,8 @@ export default function ChatScreen() {
{!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>} {!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>}
</View> </View>
{closed ? <View style={styles.card}> {closed ? <View style={styles.card}>
<Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text> <Text style={styles.muted}>Предыдущая беседа завершена.</Text>
<Button title={creating ? "Создание…" : "Начать новый диалог"} disabled={creating} onPress={() => void createDialog()} /> <Button title="Продолжить общение" onPress={() => router.replace("/dialogs")} />
{error && <ErrorNotice error={error} retry={() => void createDialog()} />}
</View> : <View style={styles.card}> </View> : <View style={styles.card}>
<Field label="Новое сообщение" multiline value={text} onChangeText={setText} /> <Field label="Новое сообщение" multiline value={text} onChangeText={setText} />
<View style={styles.row}> <View style={styles.row}>
@@ -1,65 +1,36 @@
import { useInfiniteQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link, useRouter } from "expo-router"; import { Link, useRouter } from "expo-router";
import React, { useState } from "react"; import React, { useEffect, useRef } from "react";
import { ScrollView, Text, View } from "react-native"; import { ScrollView, Text } from "react-native";
import { useApp } from "../../src/app-context"; import { useApp } from "../../src/app-context";
import { dialogApi } from "../../src/services"; import { dialogApi } from "../../src/services";
import { Button, ErrorNotice, Header, Loading, styles } from "../../src/ui"; import { ErrorNotice, Header, Loading, styles } from "../../src/ui";
const statusLabels = {
open: "Открыт",
waiting_for_company: "Ожидает ответа компании",
waiting_for_client: "Ожидает вашего ответа",
closed: "Закрыт",
};
export default function DialogsScreen() { export default function DialogsScreen() {
const app = useApp(); const app = useApp();
const router = useRouter(); const router = useRouter();
const [creating, setCreating] = useState(false); const requestKey = useRef(crypto.randomUUID());
const [createError, setCreateError] = useState<unknown>(); const chat = useQuery({
const dialogs = useInfiniteQuery({ queryKey: ["current-dialog", requestKey.current],
queryKey: ["dialogs"], queryFn: () => dialogApi.create(requestKey.current),
queryFn: ({ pageParam }) => dialogApi.list(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (page) => page.next_cursor ?? undefined,
enabled: app.authStatus === "authenticated", enabled: app.authStatus === "authenticated",
}); });
const createDialog = async () => { useEffect(() => {
setCreating(true); if (chat.data) router.replace(`/dialogs/${chat.data.dialog_id}`);
setCreateError(undefined); }, [chat.data, router]);
try {
const dialog = await dialogApi.create(crypto.randomUUID());
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setCreateError(error);
} finally {
setCreating(false);
}
};
if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}> if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} /> <Header status={app.authStatus} realtime={app.realtimeState} />
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text> <Text accessibilityRole="header" style={styles.title}>Чат</Text>
<Text style={styles.text}>История доступна после авторизации. Отправьте сообщение на главной странице, чтобы войти.</Text> <Text style={styles.text}>Чат с компанией доступен после авторизации. Отправьте сообщение на главной странице, чтобы войти.</Text>
<Link href="/" style={styles.link}>На главную</Link> <Link href="/" style={styles.link}>На главную</Link>
</ScrollView>; </ScrollView>;
const items = dialogs.data?.pages.flatMap((page) => page.items) ?? [];
return <ScrollView contentContainerStyle={styles.page}> return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} /> <Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text> <Text accessibilityRole="header" style={styles.title}>Чат</Text>
<Button title={creating ? "Создание…" : "Новый диалог"} disabled={creating} onPress={() => void createDialog()} /> {chat.isLoading && <Loading />}
{createError && <ErrorNotice error={createError} retry={() => void createDialog()} />} {chat.error && <ErrorNotice error={chat.error} retry={() => void chat.refetch()} />}
{dialogs.isLoading && <Loading />}
{dialogs.error && <ErrorNotice error={dialogs.error} retry={() => void dialogs.refetch()} />}
{!dialogs.isLoading && !items.length && <Text style={styles.muted}>Диалогов пока нет.</Text>}
{items.map((dialog) => <View key={dialog.dialog_id} style={styles.card}>
<Text style={styles.heading}>{statusLabels[dialog.status]}</Text>
<Text style={styles.muted}>Диалог {dialog.dialog_id.slice(0, 8)}</Text>
<Link href={`/dialogs/${dialog.dialog_id}`} style={styles.link}>Открыть диалог</Link>
</View>)}
{dialogs.hasNextPage && <Button title="Показать ещё" secondary disabled={dialogs.isFetchingNextPage} onPress={() => void dialogs.fetchNextPage()} />}
</ScrollView>; </ScrollView>;
} }
@@ -33,7 +33,7 @@ export const styles = StyleSheet.create({
export function Header({ status, realtime, onLogout }: { status: string; realtime: string; onLogout?: () => void }) { export function Header({ status, realtime, onLogout }: { status: string; realtime: string; onLogout?: () => void }) {
return <View style={styles.header}> return <View style={styles.header}>
<Link href="/" style={styles.link}>HAN Chat</Link> <Link href="/" style={styles.link}>HAN Chat</Link>
<Link href="/dialogs" style={styles.link}>Диалоги</Link> <Link href="/dialogs" style={styles.link}>Чат</Link>
<Link href="/profile" style={styles.link}>Профиль</Link> <Link href="/profile" style={styles.link}>Профиль</Link>
<Link href="/diagnostics" style={styles.link}>Диагностика</Link> <Link href="/diagnostics" style={styles.link}>Диагностика</Link>
<Text style={styles.badge}>{status === "authenticated" ? "Авторизован" : "Гость"} · {realtime}</Text> <Text style={styles.badge}>{status === "authenticated" ? "Авторизован" : "Гость"} · {realtime}</Text>
+7 -6
View File
@@ -67,7 +67,7 @@ Auth state machine: `guest → authorizing → bootstrapping → authenticated`;
- загрузка `GET /api/v1/public/app-config` и `/content`; - загрузка `GET /api/v1/public/app-config` и `/content`;
- приветствие, популярные вопросы, textarea, attach button, send; - приветствие, популярные вопросы, textarea, attach button, send;
- индикаторы загрузки/ошибки и повтор; - индикаторы загрузки/ошибки и повтор;
- ссылка на историю и профиль (при guest запускают auth только по явному действию); - ссылки на чат и профиль (при guest запускают auth только по явному действию);
- диагностический badge режима: guest/authenticated, WS/polling, без раскрытия token. - диагностический badge режима: guest/authenticated, WS/polling, без раскрытия token.
Выбор популярного вопроса сразу запускает тот же send flow, что ручной текст. Выбор популярного вопроса сразу запускает тот же send flow, что ручной текст.
@@ -78,14 +78,15 @@ Modal согласий отображает актуальные URL/верси
OTP вводится на странице/теме Keycloak. В MVP Keycloak сверяет mock-код из env; frontend не хранит и не проверяет код. Для тестовой среды UI может показывать только текст «используется тестовый OTP», но не получать secret из API. OTP вводится на странице/теме Keycloak. В MVP Keycloak сверяет mock-код из env; frontend не хранит и не проверяет код. Для тестовой среды UI может показывать только текст «используется тестовый OTP», но не получать secret из API.
### 5.3. Диалоги и чат ### 5.3. Чат
- история: `GET /dialogs`, cursor pagination; - в MVP frontend показывает пользователю один чат с компанией без истории диалогов;
- карточка: статус, сообщения, composer, attachment; - пункт навигации «Чат» вызывает `POST /dialogs`: backend возвращает текущий активный диалог или создаёт новый, после чего frontend открывает `/dialogs/{dialog_id}`;
- backend-модель диалогов и `GET /dialogs` сохраняются для будущего возврата истории;
- экран чата содержит статус, сообщения, composer и attachment;
- сообщения сортируются по `created_at asc`, дубли объединяются по `message_id`; - сообщения сортируются по `created_at asc`, дубли объединяются по `message_id`;
- `waiting_for_company`, `waiting_for_client`, `closed` отображаются русскими подписями; - `waiting_for_company`, `waiting_for_client`, `closed` отображаются русскими подписями;
- экран истории содержит CTA «Новый диалог» через `POST /dialogs`; - завершённая беседа readonly; CTA «Продолжить общение» повторно открывает текущий чат через `POST /dialogs`;
- closed dialog readonly и содержит CTA «Начать новый диалог»;
- промежуточный safety `203` клиенту не показывается: send request остаётся в progress до финального ответа. - промежуточный safety `203` клиенту не показывается: send request остаётся в progress до финального ответа.
### 5.4. Профиль ### 5.4. Профиль