实现合并转发(第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:
co-authored by
Claude Opus 5
parent
c709abbfa4
commit
5bc3fee20b
@@ -14270,3 +14270,64 @@ div.toast.show {
|
||||
color: #fff;
|
||||
background: #347bd7;
|
||||
}
|
||||
|
||||
/* 合并转发卡片(widgets/forward-bundle.jsx,第4章 4.20)。
|
||||
服务端 mode="bundle" 生成的消息不是 Drafty,单独渲染成一张可展开的"聊天记录"卡片。 */
|
||||
.forward-bundle {
|
||||
min-width: 11rem;
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.forward-bundle-title {
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forward-bundle-lines {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.forward-bundle-lines li {
|
||||
overflow: hidden;
|
||||
margin: 0.15rem 0;
|
||||
color: var(--clr-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.forward-bundle-lines li b {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forward-bundle-more,
|
||||
.forward-bundle-count {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0.4rem 0 0;
|
||||
padding: 0.35rem 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--clr-divider, #e7e9ec);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
color: var(--clr-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.forward-bundle-more {
|
||||
color: #347bd7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forward-bundle-comment {
|
||||
margin-top: 0.4rem;
|
||||
color: var(--clr-text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -22356,6 +22356,9 @@ class BatchForwardDialog extends (react__WEBPACK_IMPORTED_MODULE_0___default().C
|
||||
});
|
||||
}
|
||||
}
|
||||
forwardMode() {
|
||||
return this.state.multiSelect ? 'bundle' : 'separate';
|
||||
}
|
||||
handleCancelConfirm() {
|
||||
this.setState({
|
||||
confirmTargets: null,
|
||||
@@ -22382,7 +22385,7 @@ class BatchForwardDialog extends (react__WEBPACK_IMPORTED_MODULE_0___default().C
|
||||
sending: true,
|
||||
error: null
|
||||
});
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.forwardMessages)('separate', sources, this.state.confirmTargets, this.state.comment.trim()).then(result => {
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.forwardMessages)(this.forwardMode(), sources, this.state.confirmTargets, this.state.comment.trim()).then(result => {
|
||||
const failed = result && result.failed || [];
|
||||
if (failed.length) {
|
||||
this.setState({
|
||||
@@ -22429,7 +22432,7 @@ class BatchForwardDialog extends (react__WEBPACK_IMPORTED_MODULE_0___default().C
|
||||
value: this.state.comment,
|
||||
onChange: this.handleCommentChange,
|
||||
disabled: this.state.sending,
|
||||
placeholder: '[逐条转发]共' + this.props.seqs.length + '条消息'
|
||||
placeholder: (this.forwardMode() === 'bundle' ? '[合并转发]共' : '[逐条转发]共') + this.props.seqs.length + '条消息'
|
||||
}, void 0, false), this.state.error ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
||||
className: "auth-field-error",
|
||||
children: this.state.error
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user