完成 IM Web Android 端产品页面开发

This commit is contained in:
richjohnrambo
2026-08-25 18:30:40 +08:00
parent ca5e74ee98
commit ba8f48ee83
19 changed files with 20783 additions and 54 deletions
+278
View File
@@ -0,0 +1,278 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
const navigate = view => HashNavigation.navigateTo(view);
const VIEW_CONFIG = {
'chat-inbound-request': {relationship: 'pending_inbound', variant: 'inbound-request'},
'chat-inbound-message': {relationship: 'pending_inbound', variant: 'inbound-message'},
'chat-outbound-request': {relationship: 'pending_outbound', variant: 'outbound-request'},
'chat-status': {relationship: 'friend', variant: 'status'},
'chat-friend': {relationship: 'friend', variant: 'friend'},
'chat-media': {relationship: 'friend', variant: 'media'},
'chat-blocked': {relationship: 'blocked_by_peer', variant: 'blocked'},
'chat-deleted': {relationship: 'none', variant: 'deleted'},
'chat-standard': {relationship: 'friend', variant: 'standard'},
'chat-attachments': {relationship: 'friend', variant: 'attachments'},
'chat-mixed': {relationship: 'friend', variant: 'mixed'},
'chat-contact-card': {relationship: 'friend', variant: 'contact-card'}
};
const ATTACHMENTS = [
{id: 'photo', icon: 'image', label: '照片'},
{id: 'camera', icon: 'photo_camera', label: '拍摄'},
{id: 'call', icon: 'call', label: '视频语音'},
{id: 'card', icon: 'contact_page', label: '名片'},
{id: 'file', icon: 'folder', label: '文件'},
{id: 'favorite', icon: 'bookmark', label: '收藏'},
{id: 'video', icon: 'videocam', label: '视频通话'}
];
function Icon({children}) {
return <i className="material-icons" aria-hidden="true">{children}</i>;
}
function Toast({message, onDone}) {
React.useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(onDone, 2200);
return () => window.clearTimeout(timer);
}, [message, onDone]);
return message ? <div className="chat-toast" role="status">{message}</div> : null;
}
function ChatAvatar({mine = false, peer = false}) {
return <div className="chat-avatar"><img src={mine ? 'img/bkg/p05.jpg' : peer ? 'img/bkg/p09.jpg' : 'img/bkg/p14.jpg'} alt="" /></div>;
}
function Status({value, failedReason}) {
if (!value && !failedReason) return null;
return <div className={'chat-message-status' + (failedReason ? ' failed' : '')}>
{failedReason ? <Icon>error</Icon> : null}<span>{failedReason || value}</span>
</div>;
}
function FileCard() {
return <div className="chat-file-card">
<div><strong>365天艾宾浩斯记忆曲线复习计划.pdf</strong><small>115KB</small></div>
<span className="chat-file-icon">P</span>
</div>;
}
function ContactCard({onOpen}) {
return <button type="button" className="chat-contact-card" onClick={onOpen}>
<span><img src="img/bkg/p15.jpg" alt="" /><strong>栗子来了</strong></span>
<small>个人名片 · 账号 QL1024</small>
</button>;
}
function VoiceMessage({duration = 6, playing = false}) {
return <div className="chat-voice-message"><Icon>{playing ? 'graphic_eq' : 'volume_up'}</Icon><strong>{duration}″</strong><span /></div>;
}
function ImageMessage({progress}) {
return <div className="chat-image-message">
<img src="img/bkg/p22.jpg" alt="会话图片" />
{progress < 100 ? <div className="chat-upload-progress"><progress value={progress} max="100" /><strong>{progress}%</strong></div> : null}
</div>;
}
function Bubble({mine = false, children, status, failedReason, type = 'text', onOpenCard}) {
return <div className={'chat-message-row' + (mine ? ' mine' : '')}>
{!mine ? <ChatAvatar peer /> : null}
<div className="chat-message-stack">
{type === 'file' ? <FileCard /> : type === 'card' ? <ContactCard onOpen={onOpenCard} /> :
<div className={'chat-bubble ' + type + (mine ? ' mine' : '')}>{children}</div>}
<Status value={status} failedReason={failedReason} />
</div>
{mine ? <ChatAvatar mine /> : null}
</div>;
}
function RelationNotice({children, action, onAction}) {
return <div className="chat-relation-notice"><span>{children}</span>{action ? <button type="button" onClick={onAction}>{action}</button> : null}</div>;
}
function ChatTimeline({variant, uploadProgress, sendStatus, onRetry, onOpenCard, onFriendAction}) {
if (variant === 'inbound-request' || variant === 'inbound-message') {
return <>
<p className="chat-time">7月30日 11:40</p>
<Bubble>{variant === 'inbound-request' ? '请求添加你为好友' : '我是祈万事安好'}</Bubble>
<RelationNotice>以上是打招呼的消息</RelationNotice>
<div className="chat-request-actions"><button type="button" onClick={() => onFriendAction('declined')}>拒绝</button><button type="button" className="primary" onClick={() => onFriendAction('accepted')}>通过申请</button></div>
</>;
}
if (variant === 'outbound-request') {
return <>
<p className="chat-time">7月30日 11:40</p>
<Bubble mine status="已发送">请求添加你为好友</Bubble>
<RelationNotice>好友申请已发送,等待对方处理</RelationNotice>
</>;
}
if (variant === 'status') {
return <>
<p className="chat-time">7月30日 11:40</p>
<Bubble mine status={sendStatus}>我是祈万事安好</Bubble>
<RelationNotice>你已添加安妮,现在可以开始聊天。</RelationNotice>
</>;
}
if (variant === 'friend') {
return <>
<p className="chat-time">8月27日 11:11</p>
<RelationNotice>你们已成为好友,现在可以开始聊天</RelationNotice>
<Bubble>我们已成为好友,现在我们可以开始聊天</Bubble>
<Bubble mine status="已读">你还好吗?</Bubble>
</>;
}
if (variant === 'media') {
return <>
<p className="chat-time">8月27日 11:11</p>
<RelationNotice>接雨彤创建群聊</RelationNotice>
<Bubble mine status="已读">你还好吗?</Bubble>
<Bubble mine type="image"><ImageMessage progress={uploadProgress} /></Bubble>
<Bubble mine type="voice"><VoiceMessage duration={6} playing /></Bubble>
<Bubble type="voice"><VoiceMessage duration={6} /></Bubble>
</>;
}
if (variant === 'blocked') {
return <>
<p className="chat-time">7月30日 11:40</p>
<Bubble mine status="已读">请求添加你为好友</Bubble>
<RelationNotice>你已添加了安妮,现在可以开始聊天。</RelationNotice>
<p className="chat-time">7月30日 13:00</p>
<Bubble mine status="已读">这边在网上看见你的留言,你想了解什么课程了</Bubble>
<Bubble>有课程辅导的老师?对新手友好吗?</Bubble>
<Bubble mine failedReason="消息已发送,但被对方拒收了">这个不用担心特别好上手,学起来特别简单</Bubble>
<Bubble mine failedReason="消息已发送,但被对方拒收了">拉黑我这么速度,真的牛</Bubble>
</>;
}
if (variant === 'deleted') {
return <>
<p className="chat-time">7月30日 13:00</p>
<Bubble mine status="已读">你好我是XXX推荐过来的,这边想了解课程是吗</Bubble>
<Bubble>这边目前找到更好的课程,不考虑你们家,谢谢</Bubble>
<Bubble mine status="已读">这是我们导师名片,你可以加一下,对比一下价格</Bubble>
<RelationNotice action="发送朋友验证" onAction={() => onFriendAction('resent')}>你还不是他(她)朋友。</RelationNotice>
<Bubble mine failedReason="发送失败,需重新添加好友">这么突然把我删了,真心累了</Bubble>
<RelationNotice action="发送朋友验证" onAction={() => onFriendAction('resent')}>你还不是他(她)朋友。</RelationNotice>
</>;
}
const base = <>
<p className="chat-time">7月30日 11:40</p>
<Bubble mine status="已读">请求添加你为好友</Bubble>
<RelationNotice>你已添加了安妮,现在可以开始聊天。</RelationNotice>
<p className="chat-time">7月30日 13:00</p>
<Bubble mine status="已读">你好,我是王珊珊,很高兴认识你,你怎么称呼</Bubble>
<Bubble>你好,我是安妮,想跟你咨询一下设计课程,方便?</Bubble>
</>;
if (variant === 'standard') {
return <>{base}<Bubble mine status="未读">非常方便,这是我们的课程目录,你可以看看了解一下</Bubble><Bubble mine type="file" status="未读" /></>;
}
if (variant === 'mixed') {
return <>{base}<Bubble mine type="file" status="已送达" /><Bubble type="card" onOpenCard={onOpenCard} /></>;
}
if (variant === 'contact-card') {
return <>{base}<Bubble mine status="已读">这是我们导师名片,你可以加一下,有问题随时联系他</Bubble><Bubble mine type="card" status="已送达" onOpenCard={onOpenCard} /><Bubble>好的,非常感谢,那我这边就直接联系他吧</Bubble><Bubble mine status="已读">没问题的,有疑问随时沟通</Bubble></>;
}
return base;
}
function AttachmentPanel({onChoose}) {
return <div className="chat-attachment-panel">
{ATTACHMENTS.map(item => <button type="button" key={item.id} onClick={() => onChoose(item)}><span><Icon>{item.icon}</Icon></span><small>{item.label}</small></button>)}
</div>;
}
function Composer({relationship, attachmentOpen, setAttachmentOpen, onSend}) {
const [draft, setDraft] = React.useState(sessionStorage.getItem('im-chat-draft') || '');
const canSend = relationship === 'friend';
const updateDraft = value => {
setDraft(value);
sessionStorage.setItem('im-chat-draft', value);
};
const submit = e => {
e.preventDefault();
if (!canSend || !draft.trim()) return;
onSend(draft.trim());
updateDraft('');
};
return <form className={'chat-composer' + (!canSend ? ' locked' : '')} onSubmit={submit}>
<button type="button" aria-label="语音" disabled={!canSend} onClick={() => navigate('voice-ready')}><Icon>record_voice_over</Icon></button>
<input value={draft} onChange={e => updateDraft(e.target.value)} placeholder={canSend ? '输入消息' : relationship === 'pending_inbound' ? '请先处理好友申请' : relationship === 'pending_outbound' ? '等待对方通过好友申请' : '当前无法发送消息'} disabled={!canSend} />
<button type="button" aria-label="表情" disabled={!canSend} onClick={() => navigate('emoji-panel')}><Icon>sentiment_satisfied_alt</Icon></button>
{draft.trim() ? <button type="submit" className="chat-send-button">发送</button> : <button type="button" aria-label="更多" disabled={!canSend} onClick={() => setAttachmentOpen(!attachmentOpen)}><Icon>{attachmentOpen ? 'keyboard' : 'add_circle_outline'}</Icon></button>}
</form>;
}
export default function DirectMessageView({view}) {
const config = VIEW_CONFIG[view] || VIEW_CONFIG['chat-standard'];
const [relationship, setRelationship] = React.useState(config.relationship);
const [variant, setVariant] = React.useState(config.variant);
const [attachmentOpen, setAttachmentOpen] = React.useState(config.variant === 'attachments');
const [uploadProgress, setUploadProgress] = React.useState(config.variant === 'media' ? 76 : 100);
const [sendStatus, setSendStatus] = React.useState('发送中');
const [extraMessages, setExtraMessages] = React.useState([]);
const [toast, setToast] = React.useState('');
React.useEffect(() => {
if (variant !== 'status') return undefined;
const stages = ['已发送', '已送达', '已读'];
const timers = stages.map((status, index) => window.setTimeout(() => setSendStatus(status), 600 * (index + 1)));
return () => timers.forEach(timer => window.clearTimeout(timer));
}, [variant]);
React.useEffect(() => {
if (variant !== 'media' || uploadProgress >= 100) return undefined;
const timer = window.setTimeout(() => setUploadProgress(value => Math.min(value + 4, 100)), 350);
return () => window.clearTimeout(timer);
}, [variant, uploadProgress]);
const friendAction = action => {
if (action === 'declined') {
setRelationship('none');
setToast('已拒绝好友申请');
} else {
setRelationship(action === 'accepted' ? 'friend' : 'pending_outbound');
if (action === 'accepted') setVariant('friend');
setToast(action === 'accepted' ? '好友申请已通过' : '好友验证已发送');
}
};
return <div className="chat-product-page">
<header className="chat-product-header">
<button type="button" onClick={() => navigate('messages-home')} aria-label="返回消息"><Icon>arrow_back_ios_new</Icon></button>
<div><h1>接语彤</h1><span><i />在线</span></div>
<button type="button" onClick={() => navigate('friend-settings')} aria-label="会话设置"><Icon>more_vert</Icon></button>
</header>
<main className="chat-product-timeline">
<ChatTimeline variant={variant} uploadProgress={uploadProgress} sendStatus={sendStatus}
onRetry={() => setToast('正在重试发送')} onOpenCard={() => navigate('friend-profile')} onFriendAction={friendAction} />
{extraMessages.map(item => <Bubble mine status={item.status} key={item.id}>{item.text}</Bubble>)}
</main>
{relationship === 'friend' ? null : <div className="chat-permission-banner"><Icon>info_outline</Icon>{relationship === 'pending_inbound' ? '通过好友申请后即可发送消息' : relationship === 'pending_outbound' ? '好友申请正在等待处理' : relationship === 'none' ? '对方已不是你的好友,发送消息前需重新验证' : '当前关系状态下无法发送消息'}</div>}
<Composer relationship={relationship} attachmentOpen={attachmentOpen} setAttachmentOpen={setAttachmentOpen}
onSend={text => {
const id = Date.now();
setExtraMessages(items => items.concat({id, text, status: '发送中'}));
window.setTimeout(() => setExtraMessages(items => items.map(item => item.id === id ? {...item, status: '已发送'} : item)), 600);
}} />
{attachmentOpen && relationship === 'friend' ? <AttachmentPanel onChoose={item => {
setToast(`已选择${item.label},上传接口将在联调阶段接入`);
if (item.id === 'card') navigate('chat-contact-card');
if (item.id === 'photo' || item.id === 'camera') navigate('media-uploading');
if (item.id === 'call' || item.id === 'video') navigate('call-history');
if (item.id === 'file') navigate('upload-file');
if (item.id === 'favorite') navigate('favorites');
}} /> : null}
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}