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