From 39471046b5dc941779688c5313dd25802d37fc0a Mon Sep 17 00:00:00 2001 From: doublex001 <16157008+doublex001@user.noreply.gitee.com> Date: Tue, 29 Sep 2026 15:59:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E8=A1=A5=E9=BD=90=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=93=8D=E4=BD=9C=E6=80=81=EF=BC=88P0-4=EF=BC=89?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=BC=9A=E8=AF=9D/=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=88=97=E8=A1=A8/=E6=A0=87=E8=AE=B0=E5=B7=B2=E8=AF=BB/?= =?UTF-8?q?=E6=92=A4=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api: 新增 createConversation / fetchMessages / markConversationRead / recallMessage, 真实路径对齐后端 /conversations 系列;sendChatMessage 真实路径改为 /conversations/messages - hook: useConversation 接入消息列表(fetchMessages)、进入标记已读、长按撤回 - 沟通页:进入会话即标记已读;长按本人文本消息可撤回(本地覆盖为撤回态) - 类型:ChatMessage 增加 recalled;新增 MessagePage / BackendMessage / CreateConversation* --- app/chat/[id].tsx | 70 +++++++++++-- src/api/api.ts | 185 ++++++++++++++++++++++++++++++++--- src/api/index.ts | 4 + src/hooks/useConversation.ts | 98 +++++++++++++++---- src/types/index.ts | 11 +++ src/types/message.ts | 43 ++++++++ 6 files changed, 370 insertions(+), 41 deletions(-) diff --git a/app/chat/[id].tsx b/app/chat/[id].tsx index 6e6f636..ae7657d 100644 --- a/app/chat/[id].tsx +++ b/app/chat/[id].tsx @@ -1,6 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, @@ -97,16 +97,19 @@ function Avatar({ text }: { text: string }) { function TextBubble({ message, isUser, + onLongPress, }: { message: ChatMessage; isUser: boolean; + onLongPress?: () => void; }) { return ( - {message.content} - + ); } @@ -209,9 +212,11 @@ function MessageCard({ card }: { card: ChatCard }) { function MessageRow({ message, avatar, + onRecall, }: { message: ChatMessage; avatar: string; + onRecall?: () => void; }) { const isUser = message.role === 'user'; const isSystem = message.role === 'system'; @@ -220,6 +225,15 @@ function MessageRow({ return ; } + // 自己撤回的消息:居中提示,不给可点区域 + if (isUser && message.recalled) { + return ( + + 你撤回了一条消息 + + ); + } + return ( } {message.content ? ( - + ) : null} {message.card ? : null} @@ -321,11 +339,24 @@ function InputBar({ export default function ChatDetailScreen() { const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); - const { detail, loading, error, refresh, sendMessage, sending } = useConversation(id ?? ''); + const { + detail, + messages, + loading, + error, + refresh, + markRead, + sendMessage, + sending, + recall, + } = useConversation(id ?? ''); const [input, setInput] = useState(''); const scrollRef = useRef(null); - const messages = useMemo(() => detail?.messages ?? [], [detail]); + // 进入会话即标记已读(真实后端调用 /read,mock 静默降级) + useEffect(() => { + void markRead(); + }, [markRead]); const handleSend = useCallback(async () => { const text = input.trim(); @@ -336,6 +367,23 @@ export default function ChatDetailScreen() { setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100); }, [input, sending, sendMessage]); + const handleRecall = useCallback( + (message: ChatMessage) => { + if (message.recalled) return; + Alert.alert('撤回消息', '确定撤回这条消息吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '撤回', + style: 'destructive', + onPress: () => { + void recall(message.id); + }, + }, + ]); + }, + [recall], + ); + // 右上角「更多」入口已摘除:项目没有单聊菜单页,留着只能弹「功能开发中」 if (loading) { @@ -398,7 +446,15 @@ export default function ChatDetailScreen() { {showTime && message.timestamp ? ( ) : null} - + handleRecall(message) + : undefined + } + /> ); })} diff --git a/src/api/api.ts b/src/api/api.ts index 060c6b6..da11e79 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -41,6 +41,10 @@ import type { OverallScore, ConversationDetail, ChatMessage, + MessagePage, + BackendMessage, + CreateConversationPayload, + CreateConversationResult, RankOverview, Resume, ResumeAIActionResult, @@ -8666,25 +8670,176 @@ export async function fetchConversation(id: string): Promise * @returns Promise 新增的两条消息(用户 + 助手) */ export async function sendChatMessage( - _conversationId: string, + conversationId: string, content: string, ): Promise { - // TODO: const res = await fetch(`${API_BASE_URL}/api/chat/conversations/${conversationId}/messages`, { method: 'POST', body: JSON.stringify({ content }) }); return res.json(); - const now = new Date(); - const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; - const userMessage: ChatMessage = { - id: `u-${Date.now()}`, - role: 'user', - content, - timestamp: time, + if (isMockMode()) { + // @method POST + // @url /conversations/messages + const now = new Date(); + const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + const userMessage: ChatMessage = { + id: `u-${Date.now()}`, + role: 'user', + content, + timestamp: time, + }; + const hrReply: ChatMessage = { + id: `a-${Date.now()}`, + role: 'hr', + content: '收到,我会尽快查看并回复你。', + timestamp: time, + }; + return mockRequest([userMessage, hrReply]); + } + // 真实后端:/conversations/messages,body { conversationId, content } + const res = await request('/conversations/messages', { + method: 'POST', + auth: true, + body: { conversationId: Number(conversationId), content }, + }); + return [mapBackendMessage(res)]; +} + +/* ===================== 消息操作态(P0-4):创建会话 / 消息列表 / 标记已读 / 撤回 ===================== */ + +/** 后端消息 → 前端消息模型(senderScope 映射为 role;RECALLED 标记为撤回) */ +function mapBackendMessage(m: BackendMessage): ChatMessage { + const scope = (m.senderScope ?? '').toUpperCase(); + const role: ChatMessage['role'] = + scope === 'EMPLOYER' || scope === 'HR' + ? 'hr' + : scope === 'SYSTEM' + ? 'system' + : 'user'; + const recalled = m.status === 'RECALLED'; + return { + id: String(m.id), + role, + content: recalled ? '' : m.content, + timestamp: formatBackendTime(m.createdAt), + read: m.isRead, + recalled, }; - const hrReply: ChatMessage = { - id: `a-${Date.now()}`, - role: 'hr', - content: '收到,我会尽快查看并回复你。', - timestamp: time, +} + +/** ISO 时间 → "HH:mm"(本地时区,截断到分钟) */ +function formatBackendTime(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const hh = d.getHours().toString().padStart(2, '0'); + const mm = d.getMinutes().toString().padStart(2, '0'); + return `${hh}:${mm}`; +} + +/** + * 创建或获取与某用户的 DIRECT 会话(幂等:已存在则返回原会话)。 + * @method POST + * @url /conversations + * @param payload.participantUserId 对端用户 id + * @returns Promise 会话 id + */ +export async function createConversation( + payload: CreateConversationPayload, +): Promise { + if (isMockMode()) { + return mockRequest({ id: `conv-${payload.participantUserId}` }); + } + const res = await request<{ id: number }>('/conversations', { + method: 'POST', + auth: true, + body: payload, + }); + return { id: String(res.id) }; +} + +/** + * 分页获取会话消息(后端倒序返回,这里按正序交给 UI 渲染)。 + * @method GET + * @url /conversations/{id}/messages + * @param conversationId 会话 id + * @param params.page 页码(从 0 起) + * @param params.size 每页大小 + * @returns Promise 消息分页 + */ +export async function fetchMessages( + conversationId: string, + params?: { page?: number; size?: number }, +): Promise { + if (isMockMode()) { + const items = MOCK_CONVERSATION_DETAIL.messages; + return mockRequest({ + items, + total: items.length, + page: params?.page ?? 0, + size: params?.size ?? 20, + totalPages: 1, + }); + } + const res = await request<{ + items: BackendMessage[]; + total: number; + page: number; + size: number; + totalPages: number; + }>(`/conversations/${conversationId}/messages`, { + method: 'GET', + auth: true, + query: { page: params?.page ?? 0, size: params?.size ?? 20 }, + }); + return { + items: res.items.map(mapBackendMessage), + total: res.total, + page: res.page, + size: res.size, + totalPages: res.totalPages, }; - return mockRequest([userMessage, hrReply]); +} + +/** + * 将当前会话所有消息标记为已读。 + * @method POST + * @url /conversations/{id}/read + * @param conversationId 会话 id + */ +export async function markConversationRead(conversationId: string): Promise { + if (isMockMode()) { + return mockRequest(undefined); + } + await request(`/conversations/${conversationId}/read`, { + method: 'POST', + auth: true, + }); +} + +/** + * 撤回消息(仅本人发送、且后端窗口内可撤回;前端仅更新本地展示)。 + * @method POST + * @url /conversations/{conversationId}/messages/{messageId}/recall + * @param conversationId 会话 id + * @param messageId 消息 id + * @returns Promise 撤回后的消息(recalled=true) + */ +export async function recallMessage( + conversationId: string, + messageId: string, +): Promise { + if (isMockMode()) { + const now = new Date(); + const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + return mockRequest({ + id: messageId, + role: 'user', + content: '', + recalled: true, + timestamp: time, + }); + } + const res = await request( + `/conversations/${conversationId}/messages/${messageId}/recall`, + { method: 'POST', auth: true }, + ); + return mapBackendMessage(res); } /* ===================== 账号:手机号 + 验证码登录 ===================== */ diff --git a/src/api/index.ts b/src/api/index.ts index 0d593c5..2f792df 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -25,6 +25,10 @@ export { fetchConversation, fetchDimensionScores, sendChatMessage, + createConversation, + fetchMessages, + markConversationRead, + recallMessage, fetchEnterpriseCertification, fetchEnterpriseCommunication, filterEnterpriseChats, diff --git a/src/hooks/useConversation.ts b/src/hooks/useConversation.ts index 3d382ae..5ca1b7e 100644 --- a/src/hooks/useConversation.ts +++ b/src/hooks/useConversation.ts @@ -1,59 +1,119 @@ /** - * HR 单聊会话详情 hook:拉取会话详情,并提供发送消息能力。 - * 发送消息后自动刷新会话,把新消息追加到本地列表。 + * HR 单聊会话详情 hook:拉取会话与消息列表,提供发送、标记已读、撤回能力。 + * - 会话元信息走 fetchConversation(mock 与真实后端共用); + * - 消息列表走 fetchMessages(对齐后端 /conversations/{id}/messages 分页接口); + * - 进入会话即标记已读,自己发的消息支持两分钟内撤回(真实后端校验窗口)。 */ -import { useCallback, useState } from 'react'; -import { fetchConversation, sendChatMessage } from '../api'; -import type { ConversationDetail } from '../types'; +import { useCallback, useEffect, useState } from 'react'; +import { + fetchConversation, + fetchMessages, + markConversationRead, + recallMessage, + sendChatMessage, +} from '../api'; +import type { ChatMessage, ConversationDetail } from '../types'; import { useAsyncData } from './useAsyncData'; export interface ConversationState { - /** 会话详情 */ + /** 会话元信息(不含消息列表) */ detail: ConversationDetail | null; + /** 消息列表(时间正序) */ + messages: ChatMessage[]; /** 是否加载中 */ loading: boolean; /** 错误对象 */ error: Error | null; /** 刷新数据 */ refresh: () => void; + /** 进入会话时标记已读(真实后端调用 /read,mock 直接置空) */ + markRead: () => Promise; /** 发送消息 */ sendMessage: (content: string) => Promise; /** 发送中状态 */ sending: boolean; + /** 撤回消息 */ + recall: (messageId: string) => Promise; + /** 撤回中状态 */ + recalling: boolean; } export function useConversation(conversationId: string): ConversationState { const [sending, setSending] = useState(false); + const [recalling, setRecalling] = useState(false); + const [messages, setMessages] = useState([]); - const fetcher = useCallback(() => fetchConversation(conversationId), [conversationId]); - const { data, loading, error, refresh } = useAsyncData(fetcher); + const metaFetcher = useCallback( + () => fetchConversation(conversationId), + [conversationId], + ); + const { data: detail, loading, error, refresh: refreshMeta } = + useAsyncData(metaFetcher); + + const msgFetcher = useCallback( + () => fetchMessages(conversationId).then((page) => page.items), + [conversationId], + ); + const { + data: fetchedMessages, + refresh: refreshMessages, + } = useAsyncData(msgFetcher); + + // 远端消息列表变更(首次加载 / 下拉刷新)时同步到本地可写副本 + useEffect(() => { + if (fetchedMessages) setMessages(fetchedMessages); + }, [fetchedMessages]); + + const markRead = useCallback(async () => { + if (!conversationId) return; + try { + await markConversationRead(conversationId); + } catch { + // 标记已读失败不影响会话可读性,静默降级 + } + }, [conversationId]); const sendMessage = useCallback( async (content: string) => { setSending(true); try { const newMessages = await sendChatMessage(conversationId, content); - // mock 阶段:直接把新消息合并到本地数据,避免重新拉取整屏 - const current = data as ConversationDetail | null; - if (current) { - current.messages.push(...newMessages); - refresh(); - } else { - refresh(); - } + // mock 阶段:直接把新消息合并到本地列表,避免重新拉取整屏 + setMessages((prev) => [...prev, ...newMessages]); } finally { setSending(false); } }, - [conversationId, data, refresh], + [conversationId], + ); + + const recall = useCallback( + async (messageId: string) => { + setRecalling(true); + try { + const recalled = await recallMessage(conversationId, messageId); + // 用撤回后的消息覆盖本地对应项 + setMessages((prev) => prev.map((m) => (m.id === messageId ? recalled : m))); + } finally { + setRecalling(false); + } + }, + [conversationId], ); return { - detail: (data as ConversationDetail | null) ?? null, + detail: (detail as ConversationDetail | null) ?? null, + messages, loading, error, - refresh, + refresh: () => { + refreshMeta(); + refreshMessages(); + }, + markRead, sendMessage, sending, + recall, + recalling, }; } diff --git a/src/types/index.ts b/src/types/index.ts index d89c2ac..61acbba 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -100,6 +100,10 @@ export type { Interview, MatchResult, Notification, + BackendMessage, + MessagePage, + CreateConversationPayload, + CreateConversationResult, } from './message'; export type { CompareData, CompareDimensionScore, CompareJob } from './compare'; export type { @@ -294,3 +298,10 @@ export type { PasswordRuleError, } from './auth'; export type { QualificationInfo, QualificationLicense, QualificationLicenseType } from './qualification'; +export type { + ApplicationDetail, + ApplicationItem, + ApplicationStatus, + ApplicationTimelineEvent, + PageResult, +} from './application'; diff --git a/src/types/message.ts b/src/types/message.ts index 24f5627..2167285 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -102,6 +102,49 @@ export interface ChatMessage { timestamp?: string; /** 是否已读 */ read?: boolean; + /** 是否已撤回(仅自己可见撤回提示,真实后端以 status=RECALLED 体现) */ + recalled?: boolean; +} + +/** 后端原始消息(对齐 /conversations/{id}/messages 返回项) */ +export interface BackendMessage { + id: number; + conversationId: number; + senderUserId: number; + /** STUDENT / EMPLOYER / HR / SYSTEM */ + senderScope: string; + content: string; + /** SENT / RECALLED 等 */ + status: string; + recalledAt?: string | null; + createdAt: string; + isRead: boolean; +} + +/** 消息分页响应(对齐后端 MessagePageResponse) */ +export interface MessagePage { + /** 当前页消息(时间正序) */ + items: ChatMessage[]; + /** 总数 */ + total: number; + /** 当前页(从 0 起) */ + page: number; + /** 每页大小 */ + size: number; + /** 总页数 */ + totalPages: number; +} + +/** 创建/获取 DIRECT 会话请求体 */ +export interface CreateConversationPayload { + /** 对端用户 id */ + participantUserId: number; +} + +/** 创建/获取会话结果 */ +export interface CreateConversationResult { + /** 会话 id(字符串化,与前端模型一致) */ + id: string; } /** 聊天会话详情 */