import React from 'react'; import QRCode from 'qrcodejs'; import { pinyin } from 'pinyin-pro'; import HashNavigation from '../lib/navigation.js'; import { getGroups, createGroup, getGroup, updateGroup, getGroupSettings, updateGroupMemberSettings, updateMyGroupProfile, getMyGroupProfile, leaveGroup, getGroupMembers, createGroupQRCode, getGroupJoinRequests, acceptGroupJoinRequest, rejectGroupJoinRequest, getLatestGroupAnnouncement, updateCurrentGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins, deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner, getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile } from '../lib/im-api.js'; import { readCachedData, writeCachedData } from '../lib/use-cached-data.js'; // Carries the group `topic` (and anything else already in the hash) across navigation, the same way // contact-management-view.jsx threads `uid` -- these pages all need to know *which* group. const navigate = (view, topic) => { let hash = HashNavigation.setUrlSidePanel(window.location.hash, view); hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic'); HashNavigation.navigateTo(hash); }; // 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for // viewing a group member who isn't necessarily a friend -- that route keys off `uid`, not `topic`, so it // can't go through this file's own `navigate` helper above without clobbering the wrong hash param. const navigateToProfile = userId => HashNavigation.navigateTo( HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId)); 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-my-nickname', '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' ]; // Same first-pinyin-letter grouping as contact-management-view.jsx's alphaInitial -- duplicated rather // than shared, since that file's copy is unexported and this one is the only other place that needs it. function alphaInitial(name) { const ch = (name || '').trim()[0] || '#'; if (/[A-Za-z]/.test(ch)) { return ch.toUpperCase(); } if (/[一-龥]/.test(ch)) { const letter = pinyin(ch, {pattern: 'first', toneType: 'none'}); return /[a-z]/i.test(letter) ? letter.toUpperCase() : '#'; } return '#'; } function groupBy(list, keyFn) { const groups = {}; list.forEach(item => { const key = keyFn(item); (groups[key] || (groups[key] = [])).push(item); }); return Object.keys(groups).sort().map(key => [key, groups[key]]); } function shortDate(iso) { if (!iso) return ''; const d = new Date(iso); return `${d.getMonth() + 1}-${String(d.getDate()).padStart(2, '0')}`; } // -- Demo-only mock data below this line: still used by the untouched GroupChat/ChatHeader/SystemEvent // -- trio (see comment above GroupChat). Those views are a standalone design reference showing message- // -- timeline states -- the real group chat timeline is messages-view.jsx, not this file (same treatment // -- Ch.3 gave direct-message-view.jsx). Nothing else in this file reads these constants any more. 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_DEMO = { owned: [{id: 'product', name: '产品研发群', count: 18, avatar: 'img/bkg/p22.jpg'}] }; function Icon({children}) { return ; } function Header({title, back = 'group-list', backTopic, right, onRight}) { return

{title}

{right ? : }
; } function Avatar({item, icon}) { const fileId = item && item.avatar_file_id; const [url, setUrl] = React.useState(null); React.useEffect(() => { if (!fileId) { setUrl(null); return undefined; } let live = true; getFileUrl(fileId).then(u => { if (live) setUrl(u); }).catch(() => {}); return () => { live = false; }; }, [fileId]); return {url ? : {icon || 'group'}}; } function Toast({message, onDone}) { React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer); }, [message, onDone]); return message ?
{message}
: null; } // Centered overlay confirmation, docs/images/添加管理员-设置成功.png -- distinct from the bottom Toast // above (used for lighter "已同意进群申请"-style feedback that doesn't need a heavier interrupt). function CenterToast({message, onDone}) { React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 1400); return () => window.clearTimeout(timer); }, [message, onDone]); return message ?
check_circle{message}
: null; } function Search({value, onChange, placeholder = '搜索'}) { return ; } function Toggle({checked, onChange, disabled}) { return ; } function GroupList({onToast}) { // Only the unfiltered (query === '') result is worth caching -- caching a search result under the // same key would show stale search results instead of the real list on the next plain visit. const [lists, setLists] = React.useState(() => readCachedData('groups') || {owned: null, managed: null, joined: null}); const [loadErrors, setLoadErrors] = React.useState({owned: false, managed: false, joined: false}); const [category, setCategory] = React.useState('owned'); const [query, setQuery] = React.useState(''); const [creating, setCreating] = React.useState(false); const [name, setName] = React.useState(''); const [submitting, setSubmitting] = React.useState(false); const reload = React.useCallback(() => { ['owned', 'managed', 'joined'].forEach(cat => { getGroups({category: cat, query}).then(data => { const items = data.items || []; setLists(v => { const next = {...v, [cat]: items}; if (!query) writeCachedData('groups', next); return next; }); }).catch(() => { onToast('加载失败'); setLoadErrors(v => ({...v, [cat]: true})); }); }); }, [query]); React.useEffect(() => { reload(); }, [reload]); const current = lists[category]; const submit = () => { if (!name.trim() || submitting) return; setSubmitting(true); createGroup({name: name.trim()}).then(() => { setCreating(false); setName(''); onToast('群聊创建成功'); reload(); }).catch(err => onToast(err.message || '创建失败')).finally(() => setSubmitting(false)); }; return
setCreating(true)} />
{current === null ?

{loadErrors[category] ? '群列表加载失败' : '加载中…'}

: current.length ? current.map(group => ) :
groups这里还没有群聊

{category === 'owned' ? '创建一个群聊,邀请成员一起沟通' : '加入或被设置为管理员后会显示在这里'}

}
{creating ?

创建群聊

创建后可继续邀请成员和设置群资料。

: null}
; } // GroupChat/ChatHeader/SystemEvent: standalone design-reference demo (docs/images/内容沟通对话*.png), same // treatment as Ch.3's direct-message-view.jsx -- the real group timeline is messages-view.jsx, which now // has its own real relationship/announcement/join-request/role-badge wiring. Left unchanged, not rewired. function ChatHeader() { return

产品研发群

18人
; } function SystemEvent({children}) { return

{children}

; } function GroupChat({mode, onToast}) { const [joined, setJoined] = React.useState(false); return
{['group-new-request', 'group-summary'].includes(mode) ? : null}{['group-announcement', 'group-summary'].includes(mode) ? : null}

今天 10:24

{mode === 'group-invite' ?
产品研发群安妮邀请你加入群聊

一起参与新版产品的设计与研发讨论

: <>安妮邀请栗子来了加入群聊管理员通过了王珊珊的进群申请
安妮 管理员

下午评审前请大家把问题同步到文档里。

陈晨 群主

收到,我会在两点前整理一版。

}
; } function JoinRequests({topic, onToast}) { // A persistent in-chat banner (messages-view.jsx's handleShowAnnouncement/handleShowJoinRequests) // is a second entry point besides group-settings, and this whole feature remounts on every // navigation step (see GroupManagementView's `key={view}` note) -- so an admin actively working // through requests re-triggers this blank "加载中" flash on essentially every accept/reject. const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null); const [profiles, setProfiles] = React.useState({}); const [loadError, setLoadError] = React.useState(false); // Tracked in a ref (not state) so `load` doesn't need `profiles` in its dependency array -- // depending on `profiles` here would make `load` a new function every time a profile fetch // resolves, which would re-trigger the `useEffect(() => load(), [load])` below and re-fetch // the whole join-request list in a cascade, not just skip already-known profiles. const fetchedIds = React.useRef(new Set()); const load = React.useCallback(() => { if (!topic) return; getGroupJoinRequests(topic, 'all').then(data => { const list = data.items || []; writeCachedData('group-join-requests:' + topic, list); setItems(list); const ids = new Set(); list.forEach(r => { ids.add(r.user_id); if (r.inviter_id) ids.add(r.inviter_id); }); ids.forEach(id => { if (fetchedIds.current.has(id)) return; fetchedIds.current.add(id); getUserProfile(id).then(p => setProfiles(v => ({...v, [id]: p}))).catch(() => { fetchedIds.current.delete(id); }); }); }).catch(() => { onToast('加载失败'); setLoadError(true); }); }, [topic]); React.useEffect(() => { setItems((topic && readCachedData('group-join-requests:' + topic)) || null); load(); }, [topic, load]); const handle = (req, accept) => { const call = accept ? acceptGroupJoinRequest(topic, req.request_id, req.version) : rejectGroupJoinRequest(topic, req.request_id, req.version); call.then(() => { onToast(accept ? '已同意进群申请' : '已拒绝进群申请'); load(); }) .catch(err => onToast(err.message || '操作失败')); }; const nameOf = id => (profiles[id] && profiles[id].display_name) || id; const describe = req => req.source === 'qr' ? `${nameOf(req.user_id)}通过二维码分享进群` : req.inviter_id ? `${nameOf(req.inviter_id)}邀请${nameOf(req.user_id)}进群` : `${nameOf(req.user_id)}申请加入群聊`; // Exact resolved/rejected label wording isn't confirmed against the live status enum yet -- falls back // to the raw status string for anything unrecognized so a mismatch is visible instead of silently wrong. const statusLabel = {accepted: '已确定', approved: '已确定', rejected: '已拒绝', expired: '已过期'}; return
{items === null ?

{loadError ? '申请列表加载失败' : '加载中…'}

: items.length ? items.map(item =>
{describe(item)}{shortDate(item.created_at)}
{item.status === 'pending' ? : {statusLabel[item.status] || item.status}}
) :
暂无进群申请
}
; } function Announcement({topic, myRole, fromChat, onToast}) { // Shares the 'group-announcement:'+topic cache key with GroupSettings' preview snippet below -- // whichever screen loads first warms the cache for the other, and both are re-entered often (the // in-chat banner is a persistent second entry point on top of group-settings). const cachedAnnouncement = topic && readCachedData('group-announcement:' + topic); const [data, setData] = React.useState(() => cachedAnnouncement || null); const [draft, setDraft] = React.useState(() => (cachedAnnouncement && cachedAnnouncement.content) || ''); const [editing, setEditing] = React.useState(false); const [saving, setSaving] = React.useState(false); const [loadError, setLoadError] = React.useState(false); const canEdit = myRole === 'owner' || myRole === 'admin'; // Coming from the announcement banner atop the chat itself (messages-view.jsx's // handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical // path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so // "back" can return to the chat (close the sidepanel) instead of always forcing group-settings. const backView = fromChat ? '' : 'group-settings'; React.useEffect(() => { if (!topic) return; const cached = readCachedData('group-announcement:' + topic); if (cached) { setData(cached); setDraft(cached.content || ''); } getLatestGroupAnnouncement(topic).then(d => { writeCachedData('group-announcement:' + topic, d); setData(d); setDraft(d.content || ''); }).catch(() => { onToast('加载失败'); setLoadError(true); }); }, [topic]); const publish = () => { if (!draft.trim() || saving) return; setSaving(true); updateCurrentGroupAnnouncement(topic, draft.trim(), data ? data.version : 0).then(d => { writeCachedData('group-announcement:' + topic, d); setData(d); setEditing(false); onToast('群公告已发布'); }).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false)); }; if (!data) return

{loadError ? '群公告加载失败' : '加载中…'}

; return
{canEdit ?