完成 IM Web Android 端产品页面开发
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import QRCode from 'qrcodejs';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
|
||||
const navigate = view => HashNavigation.navigateTo(view);
|
||||
|
||||
export const GROUP_MANAGEMENT_VIEWS = [
|
||||
'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests',
|
||||
'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail',
|
||||
'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description',
|
||||
'group-role-message', 'group-qr', 'group-governance', 'group-admins', 'group-admin-add',
|
||||
'group-admin-success', 'group-admin-partial', 'group-admin-removed', 'group-mute-add',
|
||||
'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files', 'group-file-upload'
|
||||
];
|
||||
|
||||
const MEMBERS = [
|
||||
{id: 'annie', name: '安妮', role: '管理员', avatar: 'img/bkg/p09.jpg'},
|
||||
{id: 'lizi', name: '栗子来了', role: '成员', avatar: 'img/bkg/p15.jpg'},
|
||||
{id: 'wang', name: '王珊珊', role: '成员', avatar: 'img/bkg/p14.jpg'},
|
||||
{id: 'sun', name: '孙光洪', role: '成员', avatar: 'img/bkg/p03.jpg'},
|
||||
{id: 'lin', name: '林间风', role: '成员', avatar: 'img/bkg/p18.jpg'}
|
||||
];
|
||||
|
||||
const GROUPS = {
|
||||
owned: [{id: 'product', name: '产品研发群', count: 18, avatar: 'img/bkg/p22.jpg'}, {id: 'design', name: '设计共创小组', count: 9, avatar: 'img/bkg/p23.jpg'}],
|
||||
managed: [{id: 'launch', name: '新版发布协作群', count: 36, avatar: 'img/bkg/p21.jpg'}],
|
||||
joined: [{id: 'company', name: '全员沟通群', count: 268, avatar: 'img/bkg/p24.jpg'}, {id: 'reading', name: '读书分享会', count: 42, avatar: 'img/bkg/p19.jpg'}]
|
||||
};
|
||||
|
||||
const FILES = [
|
||||
{id: 'f1', name: '产品需求说明书.pdf', size: '2.4 MB', owner: '陈晨', time: '今天 10:20', type: 'PDF'},
|
||||
{id: 'f2', name: '交互原型最终版.fig', size: '18.6 MB', owner: '安妮', time: '昨天 18:42', type: 'FIG'},
|
||||
{id: 'f3', name: '发布计划.xlsx', size: '860 KB', owner: '栗子来了', time: '星期一', type: 'XLS'}
|
||||
];
|
||||
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
function Header({title, back = 'group-list', right, onRight}) { return <header className="group-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>; }
|
||||
function Avatar({item, icon}) { return <span className="group-avatar">{item && item.avatar ? <img src={item.avatar} alt="" /> : <Icon>{icon || 'group'}</Icon>}</span>; }
|
||||
function Toast({message, onDone}) { React.useEffect(() => {if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer);}, [message, onDone]); return message ? <div className="chat-toast" role="status">{message}</div> : null; }
|
||||
function Search({value, onChange, placeholder = '搜索'}) { return <label className="group-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>; }
|
||||
function Toggle({checked, onChange}) { return <button type="button" role="switch" aria-checked={checked} className={'group-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
|
||||
|
||||
function GroupList({empty, onToast}) {
|
||||
const [category, setCategory] = React.useState(empty ? 'managed' : 'owned');
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [name, setName] = React.useState('');
|
||||
const groups = (empty ? [] : GROUPS[category]).filter(item => item.name.includes(query));
|
||||
return <div className="group-page"><Header title="群聊" back="address-book" right="创建" onRight={() => setCreating(true)} /><Search value={query} onChange={setQuery} placeholder="搜索群聊" /><nav className="group-tabs">{[['owned', '我创建的'], ['managed', '我管理的'], ['joined', '我加入的']].map(item => <button type="button" key={item[0]} className={category === item[0] ? 'active' : ''} onClick={() => setCategory(item[0])}>{item[1]}</button>)}</nav><main className="group-list">{groups.length ? groups.map(group => <button type="button" key={group.id} onClick={() => navigate('group-settings')}><Avatar item={group} /><span><strong>{group.name}</strong><small>{group.count} 位成员</small></span><Icon>chevron_right</Icon></button>) : <div className="group-empty"><Icon>groups</Icon><strong>这里还没有群聊</strong><p>{category === 'owned' ? '创建一个群聊,邀请成员一起沟通' : '加入或被设置为管理员后会显示在这里'}</p><button type="button" onClick={() => setCreating(true)}>创建群聊</button></div>}</main>{creating ? <div className="group-modal-backdrop"><section className="group-modal"><h2>创建群聊</h2><label>群名称<input autoFocus value={name} maxLength="30" onChange={event => setName(event.target.value)} placeholder="输入群名称" /></label><p>创建后可继续邀请成员和设置群资料。</p><footer><button type="button" onClick={() => setCreating(false)}>取消</button><button type="button" className="primary" disabled={!name.trim()} onClick={() => {setCreating(false); onToast('群聊创建成功');}}>创建</button></footer></section></div> : null}</div>;
|
||||
}
|
||||
|
||||
function ChatHeader({onToast}) { return <header className="group-chat-header"><button type="button" onClick={() => navigate('group-list')}><Icon>arrow_back_ios_new</Icon></button><div><h1>产品研发群</h1><small>18人</small></div><button type="button" onClick={() => navigate('group-settings')}><Icon>more_vert</Icon></button></header>; }
|
||||
function SystemEvent({children}) { return <p className="group-system-event">{children}</p>; }
|
||||
|
||||
function GroupChat({mode, onToast}) {
|
||||
const [joined, setJoined] = React.useState(false);
|
||||
return <div className="group-page"><ChatHeader onToast={onToast} />{['group-new-request', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice" onClick={() => navigate('group-join-requests')}><Icon>person_add_alt</Icon><span><strong>新的进群申请</strong><small>2 条申请等待处理</small></span><Icon>chevron_right</Icon></button> : null}{['group-announcement', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice announcement" onClick={() => navigate('group-announcement-detail')}><Icon>campaign</Icon><span><strong>群公告</strong><small>下午三点进行版本评审,请准时参加</small></span><Icon>chevron_right</Icon></button> : null}<main className="group-chat-timeline"><p className="chat-time">今天 10:24</p>{mode === 'group-invite' ? <article className="group-invite-card"><div><Avatar item={GROUPS.owned[0]} /><span><strong>产品研发群</strong><small>安妮邀请你加入群聊</small></span></div><p>一起参与新版产品的设计与研发讨论</p><button type="button" disabled={joined} onClick={() => {setJoined(true); onToast('已加入产品研发群');}}>{joined ? '已加入' : '立即加入'}</button></article> : <><SystemEvent>安妮邀请栗子来了加入群聊</SystemEvent><SystemEvent>管理员通过了王珊珊的进群申请</SystemEvent><div className="group-chat-message"><Avatar item={MEMBERS[0]} /><div><span>安妮 <em>管理员</em></span><p>下午评审前请大家把问题同步到文档里。</p></div></div><div className="group-chat-message owner"><Avatar item={{...MEMBERS[3], avatar: 'img/bkg/p05.jpg'}} /><div><span>陈晨 <em>群主</em></span><p>收到,我会在两点前整理一版。</p></div></div></>}</main><div className="group-chat-composer"><button type="button"><Icon>record_voice_over</Icon></button><input placeholder="输入消息" /><button type="button"><Icon>sentiment_satisfied_alt</Icon></button><button type="button"><Icon>add_circle_outline</Icon></button></div></div>;
|
||||
}
|
||||
|
||||
function JoinRequests({onToast}) {
|
||||
const [items, setItems] = React.useState([{...MEMBERS[3], inviter: '安妮', status: 'pending'}, {...MEMBERS[4], inviter: '栗子来了', status: 'pending'}]);
|
||||
const handle = (id, status) => {setItems(values => values.map(item => item.id === id ? {...item, status} : item)); onToast(status === 'accepted' ? '已同意进群申请' : '已拒绝进群申请');};
|
||||
return <div className="group-page"><Header title="邀请进群申请" back="group-summary" /><main className="group-request-list">{items.map(item => <article key={item.id}><Avatar item={item} /><div><strong>{item.name}</strong><p>{item.inviter} 邀请加入群聊</p><small>今天 10:24</small></div>{item.status === 'pending' ? <span><button type="button" onClick={() => handle(item.id, 'rejected')}>拒绝</button><button type="button" className="primary" onClick={() => handle(item.id, 'accepted')}>同意</button></span> : <em>{item.status === 'accepted' ? '已同意' : '已拒绝'}</em>}</article>)}</main></div>;
|
||||
}
|
||||
|
||||
function Announcement() { return <div className="group-page"><Header title="群公告" back="group-announcement" /><main className="group-announcement-detail"><Icon>campaign</Icon><h2>版本评审安排</h2><p>本周三下午三点进行新版产品评审,请各模块负责人提前更新进度,并将待确认问题同步至需求文档。</p><footer><Avatar item={MEMBERS[0]} /><span><strong>安妮</strong><small>发布于今天 09:30</small></span></footer><small>你已阅读此公告</small></main></div>; }
|
||||
|
||||
const SETTING_LINKS = [
|
||||
['group-profile', '群资料', '产品研发群'], ['group-announcement-detail', '群公告', '版本评审安排'],
|
||||
['group-files', '群文件', '12'], ['group-governance', '群组管理', ''], ['group-nickname', '我在本群的昵称', '陈晨']
|
||||
];
|
||||
|
||||
function GroupSettings({onToast}) {
|
||||
const [pinned, setPinned] = React.useState(true); const [muted, setMuted] = React.useState(false);
|
||||
return <div className="group-page"><Header title="群聊设置" back="group-events" /><main className="group-settings"><section className="group-member-strip"><div>{MEMBERS.slice(0, 4).map(item => <button type="button" key={item.id} onClick={() => navigate('group-member-profile')}><Avatar item={item} /><small>{item.name}</small></button>)}</div><button type="button" aria-label="邀请成员" onClick={() => onToast('已打开成员邀请')}><Icon>person_add_alt</Icon></button></section><section>{SETTING_LINKS.map(item => <button type="button" key={item[0]} onClick={() => navigate(item[0])}><span>{item[1]}</span><small>{item[2]}</small><Icon>chevron_right</Icon></button>)}</section><section><div><span>置顶聊天</span><Toggle checked={pinned} onChange={setPinned} /></div><div><span>消息免打扰</span><Toggle checked={muted} onChange={setMuted} /></div><button type="button" onClick={() => navigate('history-search')}><span>查找聊天记录</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => onToast('仅清除了自己的聊天记录')}><span>清除聊天记录</span><Icon>chevron_right</Icon></button></section><button type="button" className="group-danger" onClick={() => onToast('群主需先转让群主或解散群聊')}>退出群聊</button></main></div>;
|
||||
}
|
||||
|
||||
function GroupProfile({onToast}) {
|
||||
const [name, setName] = React.useState('产品研发群');
|
||||
return <div className="group-page"><Header title="群资料" back="group-settings" right="保存" onRight={() => onToast('群资料已保存')} /><main className="group-profile"><button type="button" className="group-profile-avatar" onClick={() => navigate('group-avatar')}><Avatar item={GROUPS.owned[0]} /><span>群头像</span><Icon>chevron_right</Icon></button><label><span>群名称</span><input value={name} maxLength="30" onChange={event => setName(event.target.value)} /></label><button type="button" onClick={() => navigate('group-description')}><span>群简介</span><small>一起把产品做得更好</small><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('group-qr')}><span>群二维码</span><Icon>qr_code_2</Icon><Icon>chevron_right</Icon></button><p>18 位成员 · 群号 GRP202608</p></main></div>;
|
||||
}
|
||||
|
||||
function EditText({type, onToast}) {
|
||||
const nickname = type === 'nickname'; const [value, setValue] = React.useState(nickname ? '陈晨' : '一起把产品做得更好,及时同步进度和风险。'); const max = nickname ? 20 : 200;
|
||||
return <div className="group-page"><Header title={nickname ? '设置群昵称' : '群简介'} back="group-profile" right="完成" onRight={() => {onToast('保存成功'); window.setTimeout(() => navigate('group-profile'), 600);}} /><main className="group-edit-text">{nickname ? <input autoFocus value={value} maxLength={max} onChange={event => setValue(event.target.value)} /> : <textarea autoFocus value={value} maxLength={max} onChange={event => setValue(event.target.value)} />}<small>{value.length}/{max}</small><p>{nickname ? '群昵称仅在当前群内显示。' : '请填写群用途、规则或成员需要了解的信息。'}</p></main></div>;
|
||||
}
|
||||
|
||||
function AvatarSheet({onToast}) { return <div className="group-page"><GroupProfile onToast={onToast} /><div className="group-sheet-backdrop"><section className="group-source-sheet"><h2>设置群头像</h2><button type="button" onClick={() => {onToast('正在处理相册图片'); window.setTimeout(() => navigate('group-profile'), 600);}}><Icon>photo_library</Icon>从相册选择</button><button type="button" onClick={() => onToast('正在打开相机')}><Icon>photo_camera</Icon>拍照</button><button type="button" onClick={() => navigate('group-profile')}>取消</button></section></div></div>; }
|
||||
|
||||
function GroupQr({onToast}) {
|
||||
const ref = React.useRef(null);
|
||||
React.useEffect(() => {if (!ref.current) return; ref.current.innerHTML = ''; new QRCode(ref.current, {text: 'im://qr?token=demo-group-2026&purpose=join_group', width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});}, []);
|
||||
return <div className="group-page group-qr-page"><Header title="群二维码" back="group-profile" /><main><section className="group-qr-card"><div><Avatar item={GROUPS.owned[0]} /><span><strong>产品研发群</strong><small>18 位成员</small></span></div><div className="group-qrcode" ref={ref} /><p>扫码申请加入群聊</p><small>二维码 24 小时内有效</small></section><div className="group-qr-actions"><button type="button" onClick={() => onToast('群二维码已分享')}><Icon>share</Icon>分享</button><button type="button" onClick={() => onToast('群二维码已保存')}><Icon>download</Icon>保存</button></div></main></div>;
|
||||
}
|
||||
|
||||
const GOVERNANCE = [
|
||||
['join', '进群验证', '加入群聊需要管理员确认'], ['mute', '全员禁言', '仅群主和管理员可发言'],
|
||||
['files', '成员上传群文件', '允许普通成员上传文件'], ['private', '成员私聊', '允许成员通过群聊添加好友'],
|
||||
['invite', '成员邀请', '允许普通成员邀请新成员']
|
||||
];
|
||||
|
||||
function Governance({onToast}) {
|
||||
const [values, setValues] = React.useState({join: true, mute: false, files: true, private: true, invite: true});
|
||||
return <div className="group-page"><Header title="群组管理" back="group-settings" /><main className="group-governance"><section>{GOVERNANCE.map(item => <div key={item[0]}><span><strong>{item[1]}</strong><small>{item[2]}</small></span><Toggle checked={values[item[0]]} onChange={value => setValues(current => ({...current, [item[0]]: value}))} /></div>)}</section><section><button type="button" onClick={() => navigate('group-admins')}><span>设置群管理员</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('group-muted')}><span>禁言成员</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('group-owner-select')}><span>转让群主</span><Icon>chevron_right</Icon></button></section><button type="button" className="group-danger" onClick={() => onToast('解散群聊需要再次进行身份验证')}>解散群聊</button></main></div>;
|
||||
}
|
||||
|
||||
function AdminList({mode, onToast}) {
|
||||
const removed = mode === 'group-admin-removed';
|
||||
const admins = removed ? [] : [MEMBERS[0]];
|
||||
React.useEffect(() => {if (mode === 'group-admin-success') onToast('管理员设置成功'); if (mode === 'group-admin-partial') onToast('成功设置 1 人,1 人因名额限制失败'); if (removed) onToast('管理员已移除');}, [mode]);
|
||||
return <div className="group-page"><Header title="设置群管理员" back="group-governance" right="添加" onRight={() => navigate('group-admin-add')} /><main className="group-admin-page"><aside><Icon>info_outline</Icon><p>管理员可协助审批进群申请、管理公告和群成员。最多设置 5 名管理员。</p></aside><h2>群主</h2><article><Avatar item={{...MEMBERS[3], avatar: 'img/bkg/p05.jpg'}} /><span><strong>陈晨</strong><small>群主</small></span></article><h2>管理员 {admins.length}/5</h2>{admins.length ? admins.map(item => <article key={item.id}><Avatar item={item} /><span><strong>{item.name}</strong><small>管理员</small></span><button type="button" onClick={() => navigate('group-admin-removed')}>移除</button></article>) : <div className="group-inline-empty">暂无管理员</div>}</main></div>;
|
||||
}
|
||||
|
||||
function MemberPicker({type, onToast}) {
|
||||
const single = type === 'owner'; const mute = type === 'mute';
|
||||
const [selected, setSelected] = React.useState([]); const [query, setQuery] = React.useState('');
|
||||
const visible = MEMBERS.filter(item => item.name.includes(query) && (type !== 'admin' || item.role !== '管理员'));
|
||||
const toggle = id => setSelected(values => single ? [id] : values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
|
||||
const done = () => {if (single) {sessionStorage.setItem('im-next-owner', selected[0]); navigate('group-owner-confirm');} else if (mute) {onToast(`已禁言 ${selected.length} 名成员`); window.setTimeout(() => navigate('group-muted'), 600);} else {navigate('group-admin-success');}};
|
||||
return <div className="group-page"><Header title={single ? '选择新群主' : mute ? '添加禁言成员' : '添加管理员'} back={single ? 'group-governance' : mute ? 'group-muted' : 'group-admins'} right={`完成${selected.length ? `(${selected.length})` : ''}`} onRight={selected.length ? done : undefined} /><Search value={query} onChange={setQuery} placeholder="搜索群成员" /><main className="group-member-picker">{visible.map(item => <button type="button" key={item.id} onClick={() => toggle(item.id)}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.role}</small></span><i className={'group-check' + (selected.includes(item.id) ? ' checked' : '')}>{selected.includes(item.id) ? <Icon>check</Icon> : null}</i></button>)}</main></div>;
|
||||
}
|
||||
|
||||
function OwnerConfirm({onToast}) {
|
||||
return <div className="group-page"><MemberPicker type="owner" onToast={onToast} /><div className="group-sheet-backdrop"><section className="group-confirm-sheet"><h2>转让群主</h2><p>确认将群主转让给“栗子来了”?转让后你将变为普通管理员,此操作不可直接撤销。</p><button type="button" className="danger" onClick={() => {onToast('群主已转让'); window.setTimeout(() => navigate('group-governance'), 700);}}>确认转让</button><button type="button" onClick={() => navigate('group-owner-select')}>取消</button></section></div></div>;
|
||||
}
|
||||
|
||||
function Muted({onToast}) {
|
||||
const [items, setItems] = React.useState([MEMBERS[4]]);
|
||||
return <div className="group-page"><Header title="禁言成员" back="group-governance" right="添加" onRight={() => navigate('group-mute-add')} /><main className="group-muted-list">{items.map(item => <article key={item.id}><Avatar item={item} /><span><strong>{item.name}</strong><small>永久禁言 · 安妮操作</small></span><button type="button" onClick={() => {setItems([]); onToast('已解除禁言');}}>解除</button></article>)}</main></div>;
|
||||
}
|
||||
|
||||
function GroupFiles({upload, onToast}) {
|
||||
const [query, setQuery] = React.useState(''); const [progress, setProgress] = React.useState(upload ? 18 : 100);
|
||||
React.useEffect(() => {if (!upload || progress >= 100) return undefined; const timer = window.setTimeout(() => setProgress(value => Math.min(100, value + 7)), 300); return () => window.clearTimeout(timer);}, [upload, progress]);
|
||||
const visible = FILES.filter(item => item.name.includes(query));
|
||||
return <div className="group-page"><Header title="群文件" back="group-settings" right="上传" onRight={() => navigate('group-file-upload')} /><Search value={query} onChange={setQuery} placeholder="搜索群文件" /><main className="group-file-list">{upload ? <article className="uploading"><span className="group-file-type">ZIP</span><div><strong>研发资料归档.zip</strong><small>32.8 MB · 上传中 {progress}%</small><progress value={progress} max="100" /></div><button type="button"><Icon>close</Icon></button></article> : null}{visible.map(file => <article key={file.id}><span className="group-file-type">{file.type}</span><div><strong>{file.name}</strong><small>{file.size} · {file.owner} · {file.time}</small></div><button type="button" onClick={() => navigate('file-download')}><Icon>download</Icon></button></article>)}</main>{upload ? <div className="group-upload-sources"><button type="button"><Icon>insert_drive_file</Icon><span>文件</span></button><button type="button"><Icon>photo_library</Icon><span>照片</span></button><button type="button"><Icon>videocam</Icon><span>视频</span></button></div> : null}</div>;
|
||||
}
|
||||
|
||||
export default function GroupManagementView({view}) {
|
||||
const [toast, setToast] = React.useState(''); let content;
|
||||
if (view === 'group-list' || view === 'group-empty') content = <GroupList empty={view === 'group-empty'} onToast={setToast} />;
|
||||
else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = <GroupChat mode={view} onToast={setToast} />;
|
||||
else if (view === 'group-join-requests') content = <JoinRequests onToast={setToast} />;
|
||||
else if (view === 'group-announcement-detail') content = <Announcement />;
|
||||
else if (view === 'group-settings') content = <GroupSettings onToast={setToast} />;
|
||||
else if (view === 'group-profile') content = <GroupProfile onToast={setToast} />;
|
||||
else if (view === 'group-avatar') content = <AvatarSheet onToast={setToast} />;
|
||||
else if (view === 'group-nickname' || view === 'group-description') content = <EditText type={view === 'group-nickname' ? 'nickname' : 'description'} onToast={setToast} />;
|
||||
else if (view === 'group-qr') content = <GroupQr onToast={setToast} />;
|
||||
else if (view === 'group-governance') content = <Governance onToast={setToast} />;
|
||||
else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = <AdminList mode={view} onToast={setToast} />;
|
||||
else if (view === 'group-admin-add') content = <MemberPicker type="admin" onToast={setToast} />;
|
||||
else if (view === 'group-mute-add') content = <MemberPicker type="mute" onToast={setToast} />;
|
||||
else if (view === 'group-muted') content = <Muted onToast={setToast} />;
|
||||
else if (view === 'group-owner-select') content = <MemberPicker type="owner" onToast={setToast} />;
|
||||
else if (view === 'group-owner-confirm') content = <OwnerConfirm onToast={setToast} />;
|
||||
else content = <GroupFiles upload={view === 'group-file-upload'} onToast={setToast} />;
|
||||
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||
}
|
||||
Reference in New Issue
Block a user