实现合并转发(第4章 4.20):后端已支持 bundle 模式,补上前后两端

后端把 POST /v1/im/messages/forward 的 mode="bundle" 实现了(此前直接返回 501,前端因此
只做了逐条转发)。这次把前端两侧都补上。

接收侧(新增 widgets/forward-bundle.jsx)
- 服务端生成的这条消息 head 带 x-im-type:"forward_bundle",content 是
  {bundle_id, title, item_count, preview, items, source_refs, comment},不是 Drafty。
  前端全仓搜这个类型零命中,收到会落到 chat-message 的 "invalid content" 兜底
  (⚠ + 灰字)。本端自己发不出 bundle,但别的端发过来就会踩到。
- 渲染成一张"聊天记录"卡片:标题 + 摘要行 + 展开/收起。items[] 里已经带了每条被转发消息的
  完整快照,所以"查看完整记录"是就地展开,不额外发请求。
- 快照里的 from 是不带 usr 前缀的裸 uid(与收藏快照一致),补前缀后查本地会话缓存拿显示名;
  查不到就不显示发送者,宁可少一行也不要把一串 uid 摆在摘要前面。
- 没有正文的媒体消息用 [图片]/[视频]/[语音]/[文件]/[链接] 标签兜底。

发送侧(batch-forward-dialog.jsx)
- 转发模式改为按"用哪种方式选的目标"推导,这是参考图给的规则:
  转发—逐条转发.png 是单选一个目标、占位写 [逐条转发]共2条消息;
  转发—合并转发—多选状态.png 是多选目标、占位写 [合并转发]共16条消息。
  原来 mode 硬编码 'separate'、占位也写死 [逐条转发]。

样式(base.css)
- 新增 .forward-bundle 一组规则,避免又造一批零样式类。

验证
- 用后端 buildForwardBundle 的真实产出结构,注入真实会话的消息缓存,由真正的 ChatMessage
  渲染:卡片正常显示、未落到 invalid content;展开显示全部 5 条、收起回到 3 条;截图确认视觉正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-03 21:15:33 +08:00
co-authored by Claude Opus 5
parent c709abbfa4
commit 5bc3fee20b
8 changed files with 296 additions and 48 deletions
+10 -2
View File
@@ -108,6 +108,14 @@ export default class BatchForwardDialog extends React.Component {
}
}
// 转发模式由"用哪种方式选的目标"决定,这是参考图给的规则:
// 转发—逐条转发.png 右上"多选"未点,单选一个目标,占位写 [逐条转发]共2条消息
// 转发—合并转发—多选状态.png 右上已切"确定",勾选多个目标,占位写 [合并转发]共16条消息
// 服务端两种 mode 现在都支持了(bundle 此前返回 501,已实现)。
forwardMode() {
return this.state.multiSelect ? 'bundle' : 'separate';
}
handleCancelConfirm() {
this.setState({confirmTargets: null, error: null});
}
@@ -126,7 +134,7 @@ export default class BatchForwardDialog extends React.Component {
return;
}
this.setState({sending: true, error: null});
forwardMessages('separate', sources, this.state.confirmTargets, this.state.comment.trim()).then(result => {
forwardMessages(this.forwardMode(), sources, this.state.confirmTargets, this.state.comment.trim()).then(result => {
const failed = (result && result.failed) || [];
if (failed.length) {
this.setState({sending: false, error: `${failed.length} 条转发失败,请重试`});
@@ -155,7 +163,7 @@ export default class BatchForwardDialog extends React.Component {
"附加留言"根本没法填,comment 永远是空串。方括号那段是占位文案,不是内容。 */}
<input className="batch-forward-comment" type="text" value={this.state.comment}
onChange={this.handleCommentChange} disabled={this.state.sending}
placeholder={'[逐条转发]共' + this.props.seqs.length + '条消息'} />
placeholder={(this.forwardMode() === 'bundle' ? '[合并转发]共' : '[逐条转发]共') + this.props.seqs.length + '条消息'} />
{this.state.error ? <div className="auth-field-error">{this.state.error}</div> : null}
<div className="batch-forward-actions">
<button type="button" onClick={this.handleCancelConfirm} disabled={this.state.sending}>取消</button>
+10 -1
View File
@@ -5,6 +5,7 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import { Drafty, Tinode, TheCard } from 'tinode-sdk';
import Attachment from './attachment.jsx';
import ForwardBundle from './forward-bundle.jsx';
import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
@@ -229,6 +230,14 @@ class BaseChatMessage extends React.PureComponent {
content && typeof content == 'object' && typeof content.text == 'string') {
content = content.text;
}
// 合并转发(服务端 mode="bundle" 生成的消息)同样不是 Drafty,content 是
// {bundle_id, title, item_count, preview, items, source_refs, comment}。不认这个类型的话
// 一样会落到下面的 "invalid content" 兜底。渲染成一张可展开的"聊天记录"卡片。
let bundleCard = null;
if (this.props.head && this.props.head['x-im-type'] === 'forward_bundle' &&
content && typeof content == 'object' && (content.items || content.preview)) {
bundleCard = <ForwardBundle content={content} tinode={this.props.tinode} />;
}
const attachments = [];
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
Drafty.attachments(content, (att, i) => {
@@ -288,7 +297,7 @@ class BaseChatMessage extends React.PureComponent {
<div className={bubbleClass}>
<div className="content-meta">
<div className={textSizeClass}>
{content}
{bundleCard || content}
{attachments}
</div>
{this.props.timestamp ?
+69
View File
@@ -0,0 +1,69 @@
// 合并转发卡片(第4章 4.20)。
//
// 服务端在 `POST /v1/im/messages/forward` 的 mode="bundle" 下会生成一条消息,
// head 带 `x-im-type: "forward_bundle"`,content 形如:
// {bundle_id, title, item_count, preview:[{from,type,summary,ts}], items:[...], source_refs, comment}
// 这个结构不是 Drafty,所以不加这个渲染的话会落到 chat-message 的 "invalid content" 兜底。
//
// items[] 里已经带了每条被转发消息的完整快照,所以"查看完整记录"是就地展开,不需要额外请求。
import React from 'react';
// 快照里的 from 是不带 usr 前缀的裸 uid(与收藏快照一致)。先补前缀再查本地会话缓存拿显示名,
// 查不到就不显示发送者——宁可少一行,也不要把一串 uid 摆在摘要前面。
function senderName(tinode, from) {
const raw = (from || '').trim();
if (!raw) return '';
const uid = raw.startsWith('usr') ? raw : 'usr' + raw;
const topic = tinode && tinode.getTopic ? tinode.getTopic(uid) : null;
const pub = topic && topic.public;
return (pub && (pub.fn || pub.name)) || '';
}
const TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]'};
function lineText(entry) {
if (!entry) return '';
const summary = (entry.summary || entry.text || '').trim();
if (summary) return summary;
return TYPE_LABEL[entry.type] || '';
}
export default class ForwardBundle extends React.PureComponent {
constructor(props) {
super(props);
this.state = {expanded: false};
this.handleToggle = this.handleToggle.bind(this);
}
handleToggle(e) {
e.preventDefault();
e.stopPropagation();
this.setState(s => ({expanded: !s.expanded}));
}
render() {
const c = this.props.content || {};
const count = Number(c.item_count) || (Array.isArray(c.items) ? c.items.length : 0);
// 收起时用 preview(服务端已截到最多 3 条);展开时用 items(全量)。
const rows = this.state.expanded ? (c.items || []) : (c.preview || []);
const comment = (c.comment || '').trim();
return <div className="forward-bundle">
<div className="forward-bundle-title">{c.title || '聊天记录'}</div>
<ul className="forward-bundle-lines">
{rows.map((entry, i) => {
const who = senderName(this.props.tinode, entry.from);
return <li key={(entry.seq != null ? entry.seq : i) + ':' + i}>
{who ? <b>{who}:</b> : null}{lineText(entry)}
</li>;
})}
</ul>
{count > rows.length || this.state.expanded ?
<button type="button" className="forward-bundle-more" onClick={this.handleToggle}>
{this.state.expanded ? '收起' : `查看完整记录(共 ${count} 条)`}
</button> :
<div className="forward-bundle-count">共 {count} 条消息</div>}
{comment ? <div className="forward-bundle-comment">{comment}</div> : null}
</div>;
}
}