chore: establish V1.1 recovery baseline
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 聊天详情页 U24(重构:用 messageStore + message-bubble)
|
||||
*
|
||||
* 不变量(AGENTS.md):
|
||||
* - 第 3 条:Mutual Match 后才能聊天——sendMessage 若后端返回 MATCH_REQUIRED,
|
||||
* UI 给出引导文案;不在客户端做硬阻断,由后端守门。
|
||||
* - 第 4 条:Block/Unmatch 后会话 canSendMessage=false,输入栏禁用并显示"已停用"。
|
||||
* - 第 6 条:单向活动意向不泄露——本页只展示已建立的 Mutual 会话消息。
|
||||
* - 第 7 条:普通工作人员不能查看私人聊天——本页仅服务用户端。
|
||||
* - 第 9 条:PostgreSQL 是 Source of Truth——本地仅缓存最近活跃会话消息,冷启动从服务端拉取。
|
||||
*/
|
||||
const { messageStore, appStore, blockUser } = require('../../stores');
|
||||
const { messageService } = require('../../services');
|
||||
const { to, ROUTE, DANGER_ACTION, BUSINESS_ERROR_CODE } = require('../../constants');
|
||||
|
||||
/** 超过该间隔则显示时间分隔(5 分钟) */
|
||||
const TIME_GAP_MS = 5 * 60 * 1000;
|
||||
|
||||
Page({
|
||||
data: {
|
||||
conversationId: '',
|
||||
otherDisplayName: '',
|
||||
otherUserId: '',
|
||||
otherAvatarUrl: '',
|
||||
canSend: true,
|
||||
messages: [],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
sending: false,
|
||||
inputText: '',
|
||||
scrollIntoView: '',
|
||||
statusBarHeight: 20,
|
||||
navBarHeight: 44,
|
||||
showDangerDialog: false,
|
||||
dangerAction: DANGER_ACTION.BLOCK,
|
||||
connectionState: 'disconnected',
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const conversationId = options.id || '';
|
||||
if (!conversationId) {
|
||||
appStore.toast('会话不存在');
|
||||
wx.navigateBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// 注入状态栏高度供 ui-nav-bar 使用
|
||||
try {
|
||||
const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync();
|
||||
this.setData({
|
||||
statusBarHeight: info.statusBarHeight || 20,
|
||||
navBarHeight: info.platform === 'android' ? 48 : 44,
|
||||
});
|
||||
} catch (e) { /* 使用默认值 */ }
|
||||
|
||||
this.setData({ conversationId });
|
||||
|
||||
// 绑定 messageStore.connectionState 到页面
|
||||
this._unsubConn = messageStore.bindToPage(this, {
|
||||
connectionState: 'connectionState',
|
||||
});
|
||||
// 订阅消息列表 / 分页 / loading 变化
|
||||
this._unsubList = messageStore.subscribe((state) => this._syncFromStore(state));
|
||||
// 注册实时消息监听:收到对方消息立即标记已读(会话已处于前台)
|
||||
this._unsubRealtime = messageStore.onMessage(conversationId, (msg) => {
|
||||
if (msg.bubbleType === 'other') {
|
||||
messageStore.markConversationRead(conversationId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 打开会话:拉取最近消息 + 清未读(AGENTS 第 9 条,DB 为准)
|
||||
messageStore.openConversation(conversationId).catch((err) => appStore.toastError(err));
|
||||
// 拉取会话元数据(displayName / canSend / targetUserId / 头像)
|
||||
this.loadConversationMeta();
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this._unsubConn && this._unsubConn();
|
||||
this._unsubList && this._unsubList();
|
||||
this._unsubRealtime && this._unsubRealtime();
|
||||
if (this.data.conversationId) {
|
||||
// 关闭会话:activeConversationId 置空,后续收到对方消息将累计未读
|
||||
messageStore.closeConversation();
|
||||
}
|
||||
},
|
||||
|
||||
/** 拉取会话元数据(canSend 以服务端为准) */
|
||||
async loadConversationMeta() {
|
||||
try {
|
||||
const conv = await messageService.getConversation(this.data.conversationId);
|
||||
if (conv) {
|
||||
this.setData({
|
||||
otherDisplayName: conv.targetDisplayName || conv.otherDisplayName || '用户',
|
||||
otherUserId: conv.targetUserId || '',
|
||||
otherAvatarUrl: conv.targetAvatarUrl || '',
|
||||
canSend: conv.canSendMessage !== false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// 兜底:从 store 会话列表查找
|
||||
const local = messageStore.state.conversations.find(
|
||||
(c) => c.conversationId === this.data.conversationId,
|
||||
);
|
||||
if (local) {
|
||||
this.setData({
|
||||
otherDisplayName: local.targetDisplayName || '用户',
|
||||
otherUserId: local.targetUserId || '',
|
||||
otherAvatarUrl: local.targetAvatarUrl || '',
|
||||
canSend: local.canSendMessage !== false,
|
||||
});
|
||||
} else {
|
||||
appStore.toastError(err);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** 把 store 状态同步到页面 */
|
||||
_syncFromStore(state) {
|
||||
const cid = this.data.conversationId;
|
||||
const rawList = state.messagesByConversation[cid] || [];
|
||||
const hasMore = state.hasMoreByConversation[cid] !== false;
|
||||
const loading = state.conversationsLoading;
|
||||
const messages = this._formatMessageList(rawList);
|
||||
const patch = { messages, hasMore };
|
||||
if (loading !== this.data.loading) patch.loading = loading;
|
||||
this.setData(patch);
|
||||
|
||||
// 加载更早消息时,保持滚动位置(定位到加载前的第一条)
|
||||
if (this._loadingMore && this._preserveMsgId) {
|
||||
const idx = messages.findIndex((m) => m.messageId === this._preserveMsgId);
|
||||
if (idx >= 0) this.setData({ scrollIntoView: `msg-${idx}` });
|
||||
this._loadingMore = false;
|
||||
this._preserveMsgId = null;
|
||||
} else {
|
||||
this._scrollToLast(messages);
|
||||
}
|
||||
},
|
||||
|
||||
/** 派生时间分隔 / 头像 / 是否显示头像 */
|
||||
_formatMessageList(list) {
|
||||
const otherAvatar = this.data.otherAvatarUrl;
|
||||
const result = [];
|
||||
let lastTs = 0;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const m = list[i];
|
||||
const ts = m.sentAt || 0;
|
||||
const showTime = i === 0 || (ts && ts - lastTs > TIME_GAP_MS);
|
||||
result.push({
|
||||
...m,
|
||||
showTime,
|
||||
sentAtText: showTime ? this._formatTime(ts) : '',
|
||||
showAvatar: m.bubbleType === 'other',
|
||||
avatarUrl: m.bubbleType === 'other' ? otherAvatar : '',
|
||||
});
|
||||
lastTs = ts;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
_formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
if (d.toDateString() === now.toDateString()) return hm;
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${hm}`;
|
||||
},
|
||||
|
||||
_scrollToLast(list) {
|
||||
if (!list.length) return;
|
||||
const id = `msg-${list.length - 1}`;
|
||||
if (id !== this.data.scrollIntoView) {
|
||||
this.setData({ scrollIntoView: id });
|
||||
}
|
||||
},
|
||||
|
||||
onBack() {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
wx.navigateBack();
|
||||
} else {
|
||||
wx.switchTab({ url: ROUTE.TAB.MESSAGES });
|
||||
}
|
||||
},
|
||||
|
||||
onInput(e) {
|
||||
this.setData({ inputText: e.detail.value });
|
||||
},
|
||||
|
||||
async onSend() {
|
||||
const { inputText, conversationId, sending, canSend } = this.data;
|
||||
if (!canSend || !inputText.trim() || sending) return;
|
||||
this.setData({ sending: true, inputText: '' });
|
||||
try {
|
||||
await messageStore.sendMessage(conversationId, inputText.trim());
|
||||
} catch (err) {
|
||||
// MATCH_REQUIRED:双方互相喜欢后才能聊天(AGENTS 第 3 条,后端守门)
|
||||
if (err && err.code === BUSINESS_ERROR_CODE.MATCH_REQUIRED) {
|
||||
appStore.toast('双方互相喜欢后才能开始聊天');
|
||||
} else {
|
||||
appStore.toastError(err, '发送失败');
|
||||
}
|
||||
} finally {
|
||||
this.setData({ sending: false });
|
||||
}
|
||||
},
|
||||
|
||||
/** 重发失败消息 */
|
||||
onRetrySend(e) {
|
||||
const messageId = e.currentTarget.dataset.msgId;
|
||||
if (!messageId) return;
|
||||
messageStore
|
||||
.retrySend(this.data.conversationId, messageId)
|
||||
.catch((err) => appStore.toastError(err, '重发失败'));
|
||||
},
|
||||
|
||||
/** 滚动到顶部:加载更早消息(保持滚动位置) */
|
||||
async onScrollToUpper() {
|
||||
const { conversationId, hasMore, loadingMore } = this.data;
|
||||
if (!hasMore || loadingMore) return;
|
||||
const first = this.data.messages[0];
|
||||
this._loadingMore = true;
|
||||
this._preserveMsgId = first ? first.messageId : null;
|
||||
this.setData({ loadingMore: true });
|
||||
try {
|
||||
await messageStore.loadMoreMessages(conversationId);
|
||||
} catch (err) {
|
||||
appStore.toastError(err);
|
||||
this._loadingMore = false;
|
||||
this._preserveMsgId = null;
|
||||
} finally {
|
||||
this.setData({ loadingMore: false });
|
||||
}
|
||||
},
|
||||
|
||||
/** 右上角更多:查看资料 / 举报 / 拉黑 */
|
||||
onMore() {
|
||||
const { otherUserId } = this.data;
|
||||
if (!otherUserId) {
|
||||
appStore.toast('暂无更多信息');
|
||||
return;
|
||||
}
|
||||
wx.showActionSheet({
|
||||
itemList: ['查看 Ta 的资料', '举报 Ta', '拉黑 Ta'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
wx.navigateTo({ url: to.detail(otherUserId) });
|
||||
} else if (res.tapIndex === 1) {
|
||||
wx.navigateTo({ url: to.report(otherUserId) });
|
||||
} else if (res.tapIndex === 2) {
|
||||
this.setData({ showDangerDialog: true, dangerAction: DANGER_ACTION.BLOCK });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 危险动作确认(拉黑) */
|
||||
async onConfirmDanger(e) {
|
||||
const action = e.detail.action;
|
||||
const { otherUserId, conversationId } = this.data;
|
||||
this.setData({ showDangerDialog: false });
|
||||
if (action !== DANGER_ACTION.BLOCK) return;
|
||||
try {
|
||||
appStore.showLoading('处理中…');
|
||||
// 拉黑:触发 MatchStore + MessageStore 同步副作用(AGENTS 第 4 条即时停用)
|
||||
await blockUser(otherUserId, '用户主动拉黑');
|
||||
appStore.hideLoading();
|
||||
this.setData({ canSend: false });
|
||||
appStore.toastSuccess('已拉黑');
|
||||
} catch (err) {
|
||||
appStore.hideLoading();
|
||||
appStore.toastError(err);
|
||||
}
|
||||
},
|
||||
|
||||
onCancelDanger() {
|
||||
this.setData({ showDangerDialog: false });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "聊天",
|
||||
"usingComponents": {
|
||||
"ui-nav-bar": "/components/nav-bar/index",
|
||||
"ui-message-bubble": "/components/message-bubble/index",
|
||||
"ui-loading": "/components/loading/index",
|
||||
"ui-button": "/components/button/index",
|
||||
"ui-danger-action-dialog": "/components/danger-action-dialog/index"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 聊天详情页 U24(TypeScript)
|
||||
*
|
||||
* 不变量(AGENTS.md):
|
||||
* - 第 3 条:Mutual Match 后才能聊天
|
||||
* - 第 4 条:Block/Unmatch 后会话 canSendMessage=false
|
||||
* - 第 6 条:单向活动意向不泄露
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { messageStore, appStore, blockUser } from '../../stores';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { messageService } from '../../services';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { to, ROUTE, DANGER_ACTION, BUSINESS_ERROR_CODE } from '../../constants';
|
||||
import type { Message } from '../../types';
|
||||
|
||||
/** 超过该间隔则显示时间分隔(5 分钟) */
|
||||
const TIME_GAP_MS = 5 * 60 * 1000;
|
||||
|
||||
interface ChatData {
|
||||
conversationId: string;
|
||||
otherDisplayName: string;
|
||||
otherUserId: string;
|
||||
otherAvatarUrl: string;
|
||||
canSend: boolean;
|
||||
messages: Array<Message & { showTime: boolean; sentAtText: string; showAvatar: boolean; avatarUrl: string }>;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
sending: boolean;
|
||||
inputText: string;
|
||||
scrollIntoView: string;
|
||||
statusBarHeight: number;
|
||||
navBarHeight: number;
|
||||
showDangerDialog: boolean;
|
||||
dangerAction: string;
|
||||
connectionState: string;
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
conversationId: '',
|
||||
otherDisplayName: '',
|
||||
otherUserId: '',
|
||||
otherAvatarUrl: '',
|
||||
canSend: true,
|
||||
messages: [],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
sending: false,
|
||||
inputText: '',
|
||||
scrollIntoView: '',
|
||||
statusBarHeight: 20,
|
||||
navBarHeight: 44,
|
||||
showDangerDialog: false,
|
||||
dangerAction: DANGER_ACTION.BLOCK,
|
||||
connectionState: 'disconnected',
|
||||
},
|
||||
|
||||
onLoad(options: Record<string, string | undefined>) {
|
||||
const conversationId = options.id || '';
|
||||
if (!conversationId) {
|
||||
appStore.toast('会话不存在');
|
||||
wx.navigateBack();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync();
|
||||
this.setData({
|
||||
statusBarHeight: (info as any).statusBarHeight || 20,
|
||||
navBarHeight: (info as any).platform === 'android' ? 48 : 44,
|
||||
});
|
||||
} catch { /* 默认值 */ }
|
||||
|
||||
this.setData({ conversationId });
|
||||
|
||||
this._unsubConn = messageStore.bindToPage(this, { connectionState: 'connectionState' });
|
||||
this._unsubList = messageStore.subscribe((state: any) => this._syncFromStore(state));
|
||||
this._unsubRealtime = messageStore.onMessage(conversationId, (msg: Message) => {
|
||||
if (msg.bubbleType === 'other') {
|
||||
messageStore.markConversationRead(conversationId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
messageStore.openConversation(conversationId).catch((err: unknown) => {
|
||||
appStore.toastError(err instanceof Error ? err.message : '加载失败');
|
||||
});
|
||||
this.loadConversationMeta();
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this._unsubConn && this._unsubConn();
|
||||
this._unsubList && this._unsubList();
|
||||
this._unsubRealtime && this._unsubRealtime();
|
||||
if (this.data.conversationId) {
|
||||
messageStore.closeConversation();
|
||||
}
|
||||
},
|
||||
|
||||
async loadConversationMeta() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const conv = await messageService.getConversation(this.data.conversationId);
|
||||
if (conv) {
|
||||
this.setData({
|
||||
otherDisplayName: (conv as any).targetDisplayName || (conv as any).otherDisplayName || '用户',
|
||||
otherUserId: (conv as any).targetUserId || '',
|
||||
otherAvatarUrl: (conv as any).targetAvatarUrl || '',
|
||||
canSend: (conv as any).canSendMessage !== false,
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const local = (messageStore.state as any).conversations?.find(
|
||||
(c: any) => c.conversationId === this.data.conversationId,
|
||||
);
|
||||
if (local) {
|
||||
this.setData({
|
||||
otherDisplayName: local.targetDisplayName || '用户',
|
||||
otherUserId: local.targetUserId || '',
|
||||
otherAvatarUrl: local.targetAvatarUrl || '',
|
||||
canSend: local.canSendMessage !== false,
|
||||
});
|
||||
} else {
|
||||
appStore.toastError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_syncFromStore(state: any) {
|
||||
const cid = this.data.conversationId;
|
||||
const rawList: Message[] = state.messagesByConversation?.[cid] || [];
|
||||
const hasMore = state.hasMoreByConversation?.[cid] !== false;
|
||||
const loading = state.conversationsLoading;
|
||||
const messages = this._formatMessageList(rawList);
|
||||
const patch: Partial<ChatData> = { messages, hasMore };
|
||||
if (loading !== this.data.loading) patch.loading = loading;
|
||||
this.setData(patch);
|
||||
|
||||
if (this._loadingMore && this._preserveMsgId) {
|
||||
const idx = messages.findIndex((m) => m.messageId === this._preserveMsgId);
|
||||
if (idx >= 0) this.setData({ scrollIntoView: `msg-${idx}` });
|
||||
this._loadingMore = false;
|
||||
this._preserveMsgId = null;
|
||||
} else {
|
||||
this._scrollToLast(messages);
|
||||
}
|
||||
},
|
||||
|
||||
_formatMessageList(list: Message[]) {
|
||||
const otherAvatar = this.data.otherAvatarUrl;
|
||||
const result: Array<Message & { showTime: boolean; sentAtText: string; showAvatar: boolean; avatarUrl: string }> = [];
|
||||
let lastTs = 0;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const m = list[i];
|
||||
const ts = (m as any).sentAt || 0;
|
||||
const showTime = i === 0 || (ts && ts - lastTs > TIME_GAP_MS);
|
||||
result.push({
|
||||
...m,
|
||||
showTime,
|
||||
sentAtText: showTime ? this._formatTime(ts) : '',
|
||||
showAvatar: m.bubbleType === 'other',
|
||||
avatarUrl: m.bubbleType === 'other' ? otherAvatar : '',
|
||||
});
|
||||
lastTs = ts;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
_formatTime(ts: number | string): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(Number(ts));
|
||||
const now = new Date();
|
||||
const hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
if (d.toDateString() === now.toDateString()) return hm;
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${hm}`;
|
||||
},
|
||||
|
||||
_scrollToLast(list: typeof this.data.messages) {
|
||||
if (!list.length) return;
|
||||
const id = `msg-${list.length - 1}`;
|
||||
if (id !== this.data.scrollIntoView) this.setData({ scrollIntoView: id });
|
||||
},
|
||||
|
||||
onBack() {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) wx.navigateBack();
|
||||
else wx.switchTab({ url: ROUTE.TAB.MESSAGES });
|
||||
},
|
||||
|
||||
onInput(e: any) {
|
||||
this.setData({ inputText: (e as any).detail.value });
|
||||
},
|
||||
|
||||
async onSend() {
|
||||
const { inputText, conversationId, sending, canSend } = this.data;
|
||||
if (!canSend || !inputText.trim() || sending) return;
|
||||
this.setData({ sending: true, inputText: '' });
|
||||
try {
|
||||
// @ts-ignore
|
||||
await messageStore.sendMessage(conversationId, inputText.trim());
|
||||
} catch (err: unknown) {
|
||||
const code = (err as any)?.code;
|
||||
if (code === BUSINESS_ERROR_CODE.MATCH_REQUIRED) {
|
||||
appStore.toast('双方互相喜欢后才能开始聊天');
|
||||
} else {
|
||||
appStore.toastError(err instanceof Error ? err.message : '发送失败');
|
||||
}
|
||||
} finally {
|
||||
this.setData({ sending: false });
|
||||
}
|
||||
},
|
||||
|
||||
onRetrySend(e: any) {
|
||||
const messageId = (e.currentTarget.dataset as any).msgId as string | undefined;
|
||||
if (!messageId) return;
|
||||
// @ts-ignore
|
||||
messageStore.retrySend(this.data.conversationId, messageId).catch((err: unknown) => {
|
||||
appStore.toastError(err instanceof Error ? err.message : '重发失败');
|
||||
});
|
||||
},
|
||||
|
||||
async onScrollToUpper() {
|
||||
const { conversationId, hasMore, loadingMore } = this.data;
|
||||
if (!hasMore || loadingMore) return;
|
||||
const first = this.data.messages[0];
|
||||
this._loadingMore = true;
|
||||
this._preserveMsgId = first?.messageId || null;
|
||||
this.setData({ loadingMore: true });
|
||||
try {
|
||||
// @ts-ignore
|
||||
await messageStore.loadMoreMessages(conversationId);
|
||||
} catch (err: unknown) {
|
||||
appStore.toastError(err instanceof Error ? err.message : '加载失败');
|
||||
this._loadingMore = false;
|
||||
this._preserveMsgId = null;
|
||||
} finally {
|
||||
this.setData({ loadingMore: false });
|
||||
}
|
||||
},
|
||||
|
||||
onMore() {
|
||||
const { otherUserId } = this.data;
|
||||
if (!otherUserId) { appStore.toast('暂无更多信息'); return; }
|
||||
wx.showActionSheet({
|
||||
itemList: ['查看 Ta 的资料', '举报 Ta', '拉黑 Ta'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) wx.navigateTo({ url: to.detail(otherUserId) });
|
||||
else if (res.tapIndex === 1) wx.navigateTo({ url: to.report(otherUserId) });
|
||||
else if (res.tapIndex === 2) this.setData({ showDangerDialog: true, dangerAction: DANGER_ACTION.BLOCK });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async onConfirmDanger(e: WechatMiniprogram.CustomEvent) {
|
||||
const action = (e.detail as any)?.action as string | undefined;
|
||||
const { otherUserId } = this.data;
|
||||
this.setData({ showDangerDialog: false });
|
||||
if (action !== DANGER_ACTION.BLOCK) return;
|
||||
try {
|
||||
appStore.showLoading('处理中…');
|
||||
await blockUser(otherUserId, '用户主动拉黑');
|
||||
appStore.hideLoading();
|
||||
this.setData({ canSend: false });
|
||||
appStore.toastSuccess('已拉黑');
|
||||
} catch (err: unknown) {
|
||||
appStore.hideLoading();
|
||||
appStore.toastError(err instanceof Error ? err.message : '操作失败');
|
||||
}
|
||||
},
|
||||
|
||||
onCancelDanger() {
|
||||
this.setData({ showDangerDialog: false });
|
||||
},
|
||||
|
||||
_unsubConn: (() => {}) as () => void,
|
||||
_unsubList: (() => {}) as () => void,
|
||||
_unsubRealtime: (() => {}) as () => void,
|
||||
_loadingMore: false,
|
||||
_preserveMsgId: null as string | null,
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<!-- 聊天详情页 U24(重构:ui-nav-bar + ui-message-bubble + MessageStore) -->
|
||||
<view class="page">
|
||||
<!-- 顶部导航 -->
|
||||
<ui-nav-bar
|
||||
title="{{otherDisplayName || '聊天'}}"
|
||||
status-bar-height="{{statusBarHeight}}"
|
||||
nav-bar-height="{{navBarHeight}}"
|
||||
bind:back="onBack"
|
||||
>
|
||||
<view slot="right" class="more-btn" bindtap="onMore">⋯</view>
|
||||
</ui-nav-bar>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<view class="safety-tip">
|
||||
<text>请注意保护个人隐私,谨慎涉及转账、借款、投资等资金往来。</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="msg-list"
|
||||
scroll-into-view="{{scrollIntoView}}"
|
||||
scroll-with-animation
|
||||
bindscrolltoupper="onScrollToUpper"
|
||||
upper-threshold="80"
|
||||
>
|
||||
<!-- 加载更早消息 -->
|
||||
<ui-loading
|
||||
wx:if="{{loadingMore}}"
|
||||
mode="spinner"
|
||||
inline
|
||||
text="加载更多消息…"
|
||||
/>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<view
|
||||
wx:for="{{messages}}"
|
||||
wx:key="messageId"
|
||||
id="msg-{{index}}"
|
||||
>
|
||||
<ui-message-bubble
|
||||
bubble-type="{{item.bubbleType}}"
|
||||
content="{{item.content}}"
|
||||
sent-at="{{item.sentAt}}"
|
||||
sent-at-text="{{item.sentAtText}}"
|
||||
show-time="{{item.showTime}}"
|
||||
show-avatar="{{item.showAvatar}}"
|
||||
avatar-url="{{item.avatarUrl}}"
|
||||
send-status="{{item.sendStatus || 'sent'}}"
|
||||
data-msg-id="{{item.messageId}}"
|
||||
bind:retry="onRetrySend"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 首次加载 -->
|
||||
<ui-loading
|
||||
wx:if="{{loading && messages.length === 0}}"
|
||||
mode="spinner"
|
||||
text="加载中…"
|
||||
/>
|
||||
|
||||
<!-- 没有更多 -->
|
||||
<view wx:if="{{!hasMore && messages.length > 0}}" class="load-more-hint">
|
||||
<text>没有更多消息了</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入栏 -->
|
||||
<view wx:if="{{canSend}}" class="input-bar">
|
||||
<view class="input-wrapper">
|
||||
<input
|
||||
class="input"
|
||||
placeholder="输入消息..."
|
||||
value="{{inputText}}"
|
||||
bindinput="onInput"
|
||||
confirm-type="send"
|
||||
bindconfirm="onSend"
|
||||
disabled="{{sending}}"
|
||||
maxlength="500"
|
||||
adjust-position="{{true}}"
|
||||
hold-keyboard="{{true}}"
|
||||
/>
|
||||
<ui-button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled="{{sending || !inputText.trim()}}"
|
||||
loading="{{sending}}"
|
||||
bind:tap="onSend"
|
||||
>发送</ui-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 会话已停用 -->
|
||||
<view wx:elif="{{!canSend}}" class="blocked-tip">
|
||||
<text>该对话已停用,无法继续发送消息</text>
|
||||
</view>
|
||||
|
||||
<!-- 危险动作确认(拉黑) -->
|
||||
<ui-danger-action-dialog
|
||||
visible="{{showDangerDialog}}"
|
||||
action="{{dangerAction}}"
|
||||
target-name="{{otherDisplayName}}"
|
||||
bind:confirm="onConfirmDanger"
|
||||
bind:cancel="onCancelDanger"
|
||||
/>
|
||||
</view>
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 聊天详情页样式(重构后大幅简化,气泡主体样式封装在 ui-message-bubble 内)
|
||||
*/
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--color-bg-page);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 右上角更多按钮 */
|
||||
.more-btn {
|
||||
font-size: 40rpx;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0 var(--spacing-2);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 安全提示 */
|
||||
.safety-tip {
|
||||
background: var(--color-warning-light);
|
||||
padding: var(--spacing-2) var(--spacing-page-x);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-warning);
|
||||
text-align: center;
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
/* 消息列表 */
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
.load-more-hint {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* 输入栏 */
|
||||
.input-bar {
|
||||
background: var(--color-bg-card);
|
||||
padding: var(--spacing-2) var(--spacing-page-x);
|
||||
padding-bottom: calc(var(--spacing-2) + env(safe-area-inset-bottom));
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
background: var(--color-bg-page);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0 var(--spacing-3);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 会话已停用 */
|
||||
.blocked-tip {
|
||||
background: var(--color-bg-card);
|
||||
text-align: center;
|
||||
padding: var(--spacing-5) var(--spacing-page-x);
|
||||
padding-bottom: calc(var(--spacing-5) + env(safe-area-inset-bottom));
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-sm);
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
Reference in New Issue
Block a user