Browse Source

feat(api): 补齐消息操作态(P0-4)创建会话/消息列表/标记已读/撤回

- api: 新增 createConversation / fetchMessages / markConversationRead / recallMessage,
  真实路径对齐后端 /conversations 系列;sendChatMessage 真实路径改为 /conversations/messages
- hook: useConversation 接入消息列表(fetchMessages)、进入标记已读、长按撤回
- 沟通页:进入会话即标记已读;长按本人文本消息可撤回(本地覆盖为撤回态)
- 类型:ChatMessage 增加 recalled;新增 MessagePage / BackendMessage / CreateConversation*
master
doublex001 4 weeks ago
parent
commit
39471046b5
  1. 70
      app/chat/[id].tsx
  2. 159
      src/api/api.ts
  3. 4
      src/api/index.ts
  4. 98
      src/hooks/useConversation.ts
  5. 11
      src/types/index.ts
  6. 43
      src/types/message.ts

70
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 (
<View
<Pressable
style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleHr,
]}
onLongPress={onLongPress}
>
<Text
style={[
@ -116,7 +119,7 @@ function TextBubble({
>
{message.content}
</Text>
</View>
</Pressable>
);
}
@ -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 <SystemMessage content={message.content ?? ''} />;
}
// 自己撤回的消息:居中提示,不给可点区域
if (isUser && message.recalled) {
return (
<View style={styles.systemWrap}>
<Text style={styles.systemText}>你撤回了一条消息</Text>
</View>
);
}
return (
<View
style={[
@ -230,7 +244,11 @@ function MessageRow({
{!isUser && <Avatar text={avatar} />}
<View style={styles.messageBody}>
{message.content ? (
<TextBubble message={message} isUser={isUser} />
<TextBubble
message={message}
isUser={isUser}
onLongPress={isUser && onRecall ? onRecall : undefined}
/>
) : null}
{message.card ? <MessageCard card={message.card} /> : null}
</View>
@ -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<ScrollView>(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 ? (
<Timestamp time={message.timestamp} />
) : null}
<MessageRow message={message} avatar={detail.avatar} />
<MessageRow
message={message}
avatar={detail.avatar}
onRecall={
message.role === 'user' && message.content && !message.recalled
? () => handleRecall(message)
: undefined
}
/>
</View>
);
})}

159
src/api/api.ts

@ -41,6 +41,10 @@ import type {
OverallScore,
ConversationDetail,
ChatMessage,
MessagePage,
BackendMessage,
CreateConversationPayload,
CreateConversationResult,
RankOverview,
Resume,
ResumeAIActionResult,
@ -8666,10 +8670,12 @@ export async function fetchConversation(id: string): Promise<ConversationDetail>
* @returns Promise<ChatMessage[]> 新增的两条消息(用户 + 助手)
*/
export async function sendChatMessage(
_conversationId: string,
conversationId: string,
content: string,
): Promise<ChatMessage[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/chat/conversations/${conversationId}/messages`, { method: 'POST', body: JSON.stringify({ content }) }); return res.json();
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 = {
@ -8686,6 +8692,155 @@ export async function sendChatMessage(
};
return mockRequest([userMessage, hrReply]);
}
// 真实后端:/conversations/messages,body { conversationId, content }
const res = await request<BackendMessage>('/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,
};
}
/** 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<CreateConversationResult> 会话 id
*/
export async function createConversation(
payload: CreateConversationPayload,
): Promise<CreateConversationResult> {
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<MessagePage> 消息分页
*/
export async function fetchMessages(
conversationId: string,
params?: { page?: number; size?: number },
): Promise<MessagePage> {
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,
};
}
/**
* 将当前会话所有消息标记为已读。
* @method POST
* @url /conversations/{id}/read
* @param conversationId 会话 id
*/
export async function markConversationRead(conversationId: string): Promise<void> {
if (isMockMode()) {
return mockRequest<void>(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<ChatMessage> 撤回后的消息(recalled=true)
*/
export async function recallMessage(
conversationId: string,
messageId: string,
): Promise<ChatMessage> {
if (isMockMode()) {
const now = new Date();
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
return mockRequest<ChatMessage>({
id: messageId,
role: 'user',
content: '',
recalled: true,
timestamp: time,
});
}
const res = await request<BackendMessage>(
`/conversations/${conversationId}/messages/${messageId}/recall`,
{ method: 'POST', auth: true },
);
return mapBackendMessage(res);
}
/* ===================== 账号:手机号 + 验证码登录 ===================== */

4
src/api/index.ts

@ -25,6 +25,10 @@ export {
fetchConversation,
fetchDimensionScores,
sendChatMessage,
createConversation,
fetchMessages,
markConversationRead,
recallMessage,
fetchEnterpriseCertification,
fetchEnterpriseCommunication,
filterEnterpriseChats,

98
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<void>;
/** 发送消息 */
sendMessage: (content: string) => Promise<void>;
/** 发送中状态 */
sending: boolean;
/** 撤回消息 */
recall: (messageId: string) => Promise<void>;
/** 撤回中状态 */
recalling: boolean;
}
export function useConversation(conversationId: string): ConversationState {
const [sending, setSending] = useState(false);
const [recalling, setRecalling] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
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<ChatMessage[]>(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,
};
}

11
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';

43
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;
}
/** 聊天会话详情 */

Loading…
Cancel
Save