Files
im_webapp/src/views/group-management-view.jsx
T
Your NameandClaude Opus 5 7728afe79f 群主转让加一步密码二次确认,跟账号注销/改密码等其它高风险操作保持一致(第6章复测发现)
serveIMGroupOwnerTransfer 核实过:只认 {target_user_id, version},没有任何
action_token/二次验证机制——不是文档滞后,是这个操作本身就没有服务端强制的二次
确认。但转让群主同样不可逆、后果同样重大("确定后你将立即失去群主身份"),本项目
对同一类场景已有明确先例:personal-settings-view.jsx 的 DeleteAccountConfirm 就是
纯前端加的密码输入门槛,不指望服务端校验它,只是让"手滑点确定"变成需要多打一遍
密码才会发生的事。

OwnerSelect 原来点目标人选后只有一层"确定/取消"的确认表,点确定直接调用真实转让
接口。现在拆成两步:第一步维持原有确认文案不变,第二步新增密码输入,留空点确定
会被拦下(不发请求),填了任意内容才真正调用 transferGroupOwner。

真实验证(真实群+真实候选人):空密码点确定确认没有发出请求;填入密码后
transferGroupOwner 真实调用成功,正确跳转回群组管理页。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:04:26 +08:00

1083 lines
70 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
getGroupMemberProfile
} from '../lib/im-api.js';
import { readCachedData, writeCachedData, useDebouncedValue } 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.
// 除了跳转,还必须先把已知资料写进 'profile-seed:' 缓存 —— 目标页(第5章的 Profile)会先读这个种子,
// 因为 GET /users/{id}/profile 对非好友按隐私默认直接 403(imPrivacyAllowsProfile)。群成员里
// 大部分不是好友,不 seed 的话点进去就是"资料加载失败,请重试"。第5章自己的入口
// (contact-management-view.jsx 的 seedProfile)一直是这么做的,这里漏了。
const navigateToProfile = (topic, userId, member) => {
// 先用群成员列表里已有的字段摆一个立即可用的种子(避免导航瞬间空白),
// 但这份种子缺 capabilities/relationship——GET /groups/{topic}/members 从不返回这两个字段
// (已实测确认),Profile 组件正是靠它们判断"要不要显示添加好友按钮"和"该显示哪句失败文案"。
// 缺了它们,非好友的群成员主页会落到 cannotAddReason 的默认分支——不管真实关系是什么,
// 一律显示"好友申请正在等待验证",添加好友按钮永远不出现。
if (member && userId) {
writeCachedData('profile-seed:' + userId, {
user_id: userId,
display_name: member.group_nickname || member.display_name || userId,
avatar_file_id: member.avatar_file_id,
...(member.signature ? {signature: member.signature} : {}),
...(member.region ? {region: member.region} : {}),
...(member.gender ? {gender: member.gender} : {})
});
}
HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
// 群场景下真正该用的是 GET /groups/{topic}/members/{user_id}/profile——群上下文里权限判断更
// 宽松,实测对方哪怕不是好友也能拿到 {capabilities, relationship}(Profile 组件目标页读的
// GET /users/{id}/profile 对非好友是 403,只能回退用种子)。异步补种:请求回来后如果用户还
// 停在这个人的资料页(没有连点跳到别处),用真实数据覆盖种子并强制该页面重新读取。
if (topic && userId) {
getGroupMemberProfile(topic, userId).then(real => {
const current = readCachedData('profile-seed:' + userId) || {};
writeCachedData('profile-seed:' + userId, {...current, ...real});
window.dispatchEvent(new CustomEvent('profile-seed-updated', {detail: {uid: userId}}));
}).catch(() => {});
}
};
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]]);
}
// GET /v1/im/groups 的 name/member_count 目前不可信:handler 用的是 store.Users.GetSubs,而 store 自己的
// 注释就写着它 "Does not load Public/Trusted or Private"(store.go:834-835),可 groupListItemPayload
// 偏偏去读 sub.GetPublic() 取名字、sub.GetSubCnt() 取人数——于是每个群都返回自己的 topic id 当名字、
// 0 当人数(应该用 GetTopics,它会把 Public 拷到 subscription 上)。已记入后端问题清单。
// 在后端修好之前,用本地 Tinode 会话缓存兜底——消息首页的群名一直是从这里取的,是准的。
function groupDisplayName(tinode, group) {
if (group.name && group.name !== group.topic) return group.name;
const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null;
const pub = t && t.public;
// 群的 public 用的是 `name`(实测本地缓存就是 {name:"扫码测试群", version:1}),后端自己的
// groupName() 也是按 name/display_name/fn 这个顺序找的,保持一致。
const local = pub && (pub.name || pub.display_name || pub.fn);
return local || group.name || group.topic;
}
function groupMemberCount(tinode, group) {
if (group.member_count) return group.member_count;
const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null;
return (t && t.subcnt) || 0;
}
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 <i className="material-icons" aria-hidden="true">{children}</i>; }
// onBack:少数页面的返回目标不是"另一个侧栏视图",而是要关掉侧栏回到某个会话(需要写 path[1],
// navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。
function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) {
return <header className="group-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} 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}) {
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 <span className="group-avatar">{url ? <img src={url} 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;
}
// 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 ? <div className="group-toast-center" role="status"><Icon>check_circle</Icon><span>{message}</span></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, disabled}) {
return <button type="button" role="switch" aria-checked={checked} disabled={disabled}
className={'group-switch' + (checked ? ' checked' : '')} onClick={() => !disabled && onChange(!checked)}><i /></button>;
}
function GroupList({tinode, 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');
// 这里比别处更值得防抖:reload 对 owned/managed/joined 三个分类各发一次请求,
// 每敲一个字符就是 3 个 getGroups。
const [queryInput, setQueryInput] = React.useState('');
const query = useDebouncedValue(queryInput, 300);
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 <div className="group-page">
<Header title="群聊" back="address-book" right="创建" onRight={() => setCreating(true)} />
<Search value={queryInput} onChange={setQueryInput} placeholder="搜索群聊" />
<nav className="group-tabs">
{[['owned', '我创建的'], ['managed', '我管理的'], ['joined', '我加入的']].map(([key, label]) =>
<button type="button" key={key} className={category === key ? 'active' : ''} onClick={() => setCategory(key)}>
{label}({(lists[key] || []).length})
</button>)}
</nav>
<main className="group-list">
{current === null ? <p className="group-inline-empty">{loadErrors[category] ? '群列表加载失败' : '加载中…'}</p> :
current.length ? current.map(group =>
<button type="button" key={group.topic} onClick={() => navigate('group-settings', group.topic)}>
<Avatar item={group} />
<span><strong>{groupDisplayName(tinode, group)}</strong><small>{groupMemberCount(tinode, group)} 位成员</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>
{/* 参考图 设置昵称(1).png 明确把这同一个字段(group.name)的上限画成 11 字(0/11)——
这里原来是 30,跟编辑页的 11 对不上:建群时能起最多 30 字的名字,之后进编辑页
却连打字都打不过 11 位,改不回原来的长度。后端本身没有强制 11(今天实测 PATCH 17 字
成功),11 纯粹是这一份参考图给的前端约束,创建页应该和它保持一致,而不是反过来放宽
编辑页——市面上没有任何参考图支持 30 这个数字。 */}
<label>群名称<input autoFocus value={name} maxLength="11" 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() || submitting} onClick={submit}>创建</button>
</footer>
</section></div> : null}
</div>;
}
// 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 <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 />{['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_DEMO.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({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 <div className="group-page">
<Header title="邀请进群申请" back="group-settings" backTopic={topic} />
<main className="group-request-list">
{items === null ? <p className="group-inline-empty">{loadError ? '申请列表加载失败' : '加载中…'}</p> :
items.length ? items.map(item =>
<article key={item.request_id}>
<Avatar item={profiles[item.user_id] || {}} />
<div><strong>{describe(item)}</strong><small>{shortDate(item.created_at)}</small></div>
{item.status === 'pending' ?
<span>
<button type="button" onClick={() => handle(item, false)}>拒绝</button>
<button type="button" className="primary" onClick={() => handle(item, true)}>确定</button>
</span> :
<em>{statusLabel[item.status] || item.status}</em>}
</article>) :
<div className="group-inline-empty">暂无进群申请</div>}
</main>
</div>;
}
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.
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼 hash,所以 path[1] 是空的),返回应该
// 关掉侧栏回到那个会话。原来写的是 navigate('', topic)——navigate 只把 topic 写成 query 参数,
// path[1] 依旧为空,tinode-web 的 handleHashRoute 就把 topicSelected 清成 null,会话跟着一起关了。
// 这里显式把 topic 写回 path[1]。
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
}, [topic]);
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 <div className="group-page"><Header title="群公告" back={fromChat ? undefined : 'group-settings'} backTopic={topic} onBack={fromChat ? backToChat : undefined} /><main className="group-announcement-detail"><p className="group-inline-empty">{loadError ? '群公告加载失败' : '加载中…'}</p></main></div>;
return <div className="group-page">
<Header title="群公告" back={fromChat ? undefined : 'group-settings'} backTopic={topic} onBack={fromChat ? backToChat : undefined}
right={canEdit ? '发布' : null} onRight={canEdit ? publish : undefined} />
<main className="group-announcement-detail">
{canEdit ? <textarea autoFocus={editing} value={draft} maxLength={2000}
onChange={e => setDraft(e.target.value)} onFocus={() => setEditing(true)}
placeholder="请输入群公告内容" /> :
<>
<Icon>campaign</Icon>
<p>{data.content || '暂无群公告'}</p>
</>}
{!canEdit ? <small className="group-inline-hint">仅群主及群管理员可编辑</small> : null}
</main>
</div>;
}
function GroupSettings({topic, myUserId, tinode, onToast}) {
// This is the landing point for "back" from GroupProfile/GroupFiles/Governance/MyNicknameEditor/
// Announcement, and (per GroupManagementView's `key={view}` note) the whole subtree remounts on
// every one of those round trips -- essentially every group-settings visit after the first refetches
// from scratch, making this the single highest-traffic page in this file to seed from cache.
const [group, setGroup] = React.useState(() => (topic && readCachedData('group-settings:' + topic)) || null);
const [loadError, setLoadError] = React.useState(false);
// The member strip and AdminList/`myRole` all call the identical unfiltered getGroupMembers(topic) --
// sharing one 'group-members:'+topic cache entry (storing the raw list, sliced to 9 only for local
// display here) lets any one of the three warm it for the others.
const [members, setMembers] = React.useState(() => {
const cached = topic && readCachedData('group-members:' + topic);
return cached ? cached.slice(0, 9) : [];
});
const [announcement, setAnnouncement] = React.useState(() => {
const cached = topic && readCachedData('group-announcement:' + topic);
return cached && cached.content ? cached : null;
});
React.useEffect(() => {
if (!topic) return;
setGroup(readCachedData('group-settings:' + topic) || null);
setMembers((readCachedData('group-members:' + topic) || []).slice(0, 9));
const cachedAnn = readCachedData('group-announcement:' + topic);
setAnnouncement(cachedAnn && cachedAnn.content ? cachedAnn : null);
getGroupSettings(topic).then(g => { writeCachedData('group-settings:' + topic, g); setGroup(g); }).catch(() => { onToast('加载失败'); setLoadError(true); });
getGroupMembers(topic).then(data => {
const items = data.items || [];
writeCachedData('group-members:' + topic, items);
setMembers(items.slice(0, 9));
}).catch(() => {});
getLatestGroupAnnouncement(topic).then(d => {
writeCachedData('group-announcement:' + topic, d);
setAnnouncement(d && d.content ? d : null);
}).catch(() => {});
}, [topic]);
const setPref = patch => {
updateGroupMemberSettings(topic, {pinned: group.pinned, mute_notifications: group.mute_notifications, ...patch})
.then(() => setGroup(g => {
const next = {...g, ...patch};
writeCachedData('group-settings:' + topic, next);
return next;
})).catch(err => onToast(err.message || '设置失败'));
};
const leave = () => {
if (group && group.my_role === 'owner') { onToast('群主需先转让群主或解散群聊'); return; }
leaveGroup(topic).then(() => { onToast('已退出群聊'); navigate('group-list'); }).catch(err => onToast(err.message || '操作失败'));
};
if (!group) return <div className="group-page"><Header title="群聊设置" back="group-list" /><main className="group-settings"><p className="group-inline-empty">{loadError ? '群组信息加载失败' : '加载中…'}</p></main></div>;
const canManage = group.permissions && group.permissions.can_manage_settings;
const links = [
['group-profile', '群资料', group.name],
['group-announcement-detail', '群公告', announcement ? announcement.content : '暂无群公告'],
['group-files', '群文件', ''],
['group-my-nickname', '我在本群的昵称', '']
];
if (canManage) links.splice(3, 0, ['group-governance', '群组管理', '']);
return <div className="group-page">
<Header title="群聊设置" back="group-list" right={canManage ? '管理' : null}
onRight={canManage ? () => navigate('group-governance', topic) : undefined} />
<main className="group-settings">
<button type="button" className="group-settings-header" onClick={() => navigate('group-profile', topic)}>
<Avatar item={group} />
<span><strong>{group.name}</strong><small>{group.description || '群主很懒,还没有群简介'}</small></span>
<Icon>chevron_right</Icon>
</button>
{/* 参考图 docs/images/群聊设置.png:一行"群成员"标题(右侧带人数),下面是等宽网格,最后一格
是"添加"。这里刻意不把标题行右侧做成可点链接、也没有参考图里那个"删除"格——本分支没有
通用的群成员列表页,也没有移除成员的页面,与其跳到语义不对的页面或放一个点了没用的格子,
不如先只展示信息,缺口另行记录。 */}
<section className="group-member-section">
<div className="group-member-section-title">
<strong>群成员</strong><small>共 {group.member_count} 位成员</small>
</div>
<div className="group-member-grid">
{members.slice(0, 9).map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(topic, item.user_id, item)}>
<Avatar item={item} icon="person" /><small>{item.group_nickname || item.display_name}</small>
</button>)}
<button type="button" onClick={() => onToast('请通过转发/分享群二维码邀请成员')}>
<span className="group-member-tile-action"><Icon>add</Icon></span><small>添加</small>
</button>
</div>
</section>
<section>{links.map(item => <button type="button" key={item[0]} onClick={() => navigate(item[0], topic)}>
<span>{item[1]}</span><small>{item[2]}</small><Icon>chevron_right</Icon>
</button>)}</section>
<section>
<div><span>置顶聊天</span><Toggle checked={!!group.pinned} onChange={v => setPref({pinned: v})} /></div>
<div><span>消息免打扰</span><Toggle checked={!!group.mute_notifications} onChange={v => setPref({mute_notifications: v})} /></div>
<button type="button" onClick={() => navigate('history-search', topic)}><span>查看聊天记录</span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => {
const t = tinode ? tinode.getTopic(topic) : null;
(t ? t.delMessagesAll(false) : Promise.resolve()).then(() => onToast('聊天记录已清除')).catch(err => onToast(err.message || '操作失败'));
}}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
{/* target_type='group' 这条链路早就是通的(tinode-web.jsx 的 handleReportTopic 就在用),
这里原来一直是个"暂未开放"的死桩。跟 contact-management-view.jsx 的好友举报入口一样,
举报页要的是 target_type/target_id/target_name 三个参数,本文件的 navigate 辅助函数
只会写 topic,所以直接拼 hash。 */}
<button type="button" onClick={() => {
let hash = HashNavigation.setUrlSidePanel('', 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'group');
hash = HashNavigation.addUrlParam(hash, 'target_id', topic);
hash = HashNavigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
HashNavigation.navigateTo(hash);
}}><span>投诉/举报</span><Icon>chevron_right</Icon></button>
</section>
<button type="button" className="group-danger" onClick={leave}>退出群组</button>
</main>
</div>;
}
function GroupProfile({topic, myUserId, onToast}) {
// Shares 'group:'+topic with the nickname/description editors' own getGroup(topic) calls -- opening
// this once warms the cache for either of those too.
const [group, setGroup] = React.useState(() => (topic && readCachedData('group:' + topic)) || null);
const [loadError, setLoadError] = React.useState(false);
// "群昵称" here means "my nickname within this group" (see MyNicknameEditor below), not the group's
// own name -- confirmed live this preview row was showing group.name instead, since it's easy to
// conflate the two. GET .../members/me/profile is the only endpoint that actually returns this --
// confirmed live that the member list endpoint (getGroupMembers) never includes group_nickname on
// any item, so a version of this fix that read it from there (like MyNicknameEditor below does)
// would always show "未设置" even right after a successful save.
const [myNickname, setMyNickname] = React.useState('');
React.useEffect(() => {
if (!topic) return;
setGroup(readCachedData('group:' + topic) || null);
getGroup(topic).then(g => { writeCachedData('group:' + topic, g); setGroup(g); }).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
React.useEffect(() => {
if (!topic) return;
getMyGroupProfile(topic).then(data => {
setMyNickname(data.nickname || '');
}).catch(() => {});
}, [topic]);
if (!group) return <div className="group-page"><Header title="群资料" back="group-settings" backTopic={topic} /><main className="group-profile"><p className="group-inline-empty">{loadError ? '群组信息加载失败' : '加载中…'}</p></main></div>;
const canEdit = group.permissions && group.permissions.can_edit_profile;
const nicknameLabel = myNickname || '未设置';
return <div className="group-page">
<Header title="群资料" back="group-settings" backTopic={topic} />
<main className="group-profile">
<button type="button" className="group-profile-avatar" disabled={!canEdit} onClick={() => canEdit && navigate('group-avatar', topic)}>
<Avatar item={group} /><span>群头像</span><Icon>chevron_right</Icon>
</button>
<button type="button" onClick={() => navigate('group-my-nickname', topic)}>
<span>群昵称</span><small>{nicknameLabel}</small><Icon>chevron_right</Icon>
</button>
{canEdit ? <button type="button" onClick={() => navigate('group-nickname', topic)}>
<span>群名称</span><small>{group.name}</small><Icon>chevron_right</Icon>
</button> :
<div><span>群名称</span><small>{group.name}</small></div>}
{canEdit ? <button type="button" onClick={() => navigate('group-description', topic)}>
<span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small><Icon>chevron_right</Icon>
</button> :
<div><span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small></div>}
<button type="button" onClick={() => navigate('group-qr', topic)}><span>群二维码</span><Icon>qr_code</Icon><Icon>chevron_right</Icon></button>
<p>{group.member_count} 位成员</p>
</main>
</div>;
}
function AvatarSheet({topic, myUserId, tinode, onToast}) {
const inputRef = React.useRef(null);
const [uploading, setUploading] = React.useState(false);
const pick = () => inputRef.current && inputRef.current.click();
const onFile = e => {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file || !tinode) return;
setUploading(true);
// Fetch the current version right before the write, same optimistic-concurrency protection the
// name/description editors already have (GroupProfile below re-fetches independently and its
// possibly-stale copy isn't reused here) -- avatar updates were the one group-profile field with
// none at all.
uploadLargeFile(tinode, file)
.then(({fileId}) => getGroup(topic).then(group => updateGroup(topic, {avatar_file_id: fileId, version: group.version})))
.then(() => { onToast('群头像已更新'); navigate('group-profile', topic); })
.catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));
};
return <div className="group-page">
<GroupProfile topic={topic} onToast={onToast} />
<input ref={inputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={onFile} />
<div className="group-sheet-backdrop"><section className="group-source-sheet">
<h2>设置群头像</h2>
<button type="button" disabled={uploading} onClick={pick}><Icon>photo_library</Icon>{uploading ? '正在上传…' : '从相册选择'}</button>
<button type="button" onClick={() => navigate('group-profile', topic)}>取消</button>
</section></div>
</div>;
}
function GroupQr({topic, onToast}) {
const ref = React.useRef(null);
const [data, setData] = React.useState(null);
React.useEffect(() => {
if (!topic) return;
createGroupQRCode(topic).then(setData).catch(() => onToast('二维码生成失败'));
}, [topic]);
React.useEffect(() => {
if (!ref.current || !data) return;
ref.current.innerHTML = '';
new QRCode(ref.current, {text: data.qr_payload, width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});
}, [data]);
const group = data && data.group_snapshot;
const share = () => {
if (navigator.share && data) {
navigator.share({title: group && group.name, text: '扫码加入群聊', url: data.qr_payload}).catch(() => {});
} else if (data) {
navigator.clipboard.writeText(data.qr_payload).then(() => onToast('链接已复制')).catch(() => onToast('分享失败'));
}
};
const save = () => {
const canvas = ref.current && ref.current.querySelector('canvas');
if (!canvas) { onToast('保存失败'); return; }
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = (group ? group.name : 'group') + '-qrcode.png';
a.click();
onToast('群二维码已保存');
};
return <div className="group-page group-qr-page">
<Header title="群二维码" back="group-profile" backTopic={topic} />
<main>
<section className="group-qr-card">
<div><Avatar item={group || {}} /><span><strong>{group ? group.name : ''}</strong><small>{group ? group.member_count : 0} 位成员</small></span></div>
<div className="group-qrcode" ref={ref} />
<p>使用APP扫码二维码,加入群聊</p>
</section>
<div className="group-qr-actions">
<button type="button" onClick={share}><Icon>share</Icon>分享好友</button>
<button type="button" onClick={save}><Icon>download</Icon>保存至相册</button>
</div>
</main>
</div>;
}
// The five backend-persisted governance fields (server/im_http.go imGroupGovernancePatchRequest) --
// docs/images/群组管理.png showed a 4th toggle "群成员修改昵称" that doesn't correspond to any real
// governance field (its own description text also doesn't match member_upload_files' actual meaning,
// which a *different*, buggier draft screenshot happened to label correctly). Shipping a toggle with
// nothing to persist would be worse than a label mismatch, so this uses the mock's original 5-key
// structure -- which lines up exactly with the 5 real backend fields -- with corrected descriptions.
const GOVERNANCE_FIELDS = [
['join_approval', '进群验证', '开启后,他人申请加入群聊需群主或管理员同意'],
['mute_all', '全体禁言', '开启后,仅群主和管理员可以发言'],
['member_upload_files', '群成员文件上传', '关闭后,普通成员将无法上传群文件'],
['member_invite', '群成员邀请', '关闭后,仅群主和管理员可以邀请新成员进群'],
['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']
];
function Governance({topic, tinode, onToast}) {
// Router page for AdminList/Muted/OwnerSelect/the avatar editor -- remounts (per GroupManagementView's
// `key={view}` note) and reflashes its 5 toggles every time the user finishes one of those sub-tasks
// and returns, so this is one of the highest-traffic pages in the file.
const [gov, setGov] = React.useState(() => (topic && readCachedData('group-governance:' + topic)) || null);
const [dissolving, setDissolving] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
setGov(readCachedData('group-governance:' + topic) || null);
getGroupGovernance(topic).then(d => { writeCachedData('group-governance:' + topic, d); setGov(d); }).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const setField = (key, value) => {
updateGroupGovernance(topic, {[key]: value}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
.catch(err => onToast(err.message || '设置失败'));
};
const dissolve = () => {
if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
const t = tinode ? tinode.getTopic(topic) : null;
if (!t) return;
setDissolving(true);
t.delTopic(true).then(() => { onToast('群聊已解散'); navigate('group-list'); })
.catch(err => onToast(err.message || '解散失败')).finally(() => setDissolving(false));
};
if (!gov) return <div className="group-page"><Header title="群组管理" back="group-settings" backTopic={topic} /><main className="group-governance"><p className="group-inline-empty">{loadError ? '群组管理加载失败' : '加载中…'}</p></main></div>;
return <div className="group-page">
<Header title="群组管理" back="group-settings" backTopic={topic} />
<main className="group-governance">
<button type="button" onClick={() => navigate('group-avatar', topic)}><span>设置群头像</span><Icon>chevron_right</Icon></button>
<section>
<button type="button" onClick={() => navigate('group-join-requests', topic)}><span>邀请进群申请</span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => navigate('group-muted', topic)}><span>禁言的群成员</span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => navigate('group-admins', topic)}><span>设置管理员</span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => navigate('group-owner-select', topic)}><span>群主管理权转让</span><Icon>chevron_right</Icon></button>
</section>
<section>{GOVERNANCE_FIELDS.map(([key, label, desc]) => <div key={key}>
<span><strong>{label}</strong><small>{desc}</small></span>
<Toggle checked={!!gov[key]} disabled={gov[key + '_editable'] === false} onChange={v => setField(key, v)} />
</div>)}</section>
<button type="button" className="group-danger" disabled={dissolving} onClick={dissolve}>解散群组</button>
</main>
</div>;
}
function deriveOwnerAndAdmins(items) {
return {owner: items.find(m => m.role === 'owner') || null, admins: items.filter(m => m.role === 'admin')};
}
function AdminList({topic, onToast}) {
// Shares the same 'group-members:'+topic cache entry as GroupSettings' member strip and
// GroupManagementView's `myRole` lookup -- all three call the identical unfiltered getGroupMembers.
const cachedMembers = topic && readCachedData('group-members:' + topic);
const seed = cachedMembers ? deriveOwnerAndAdmins(cachedMembers) : {owner: null, admins: null};
const [owner, setOwner] = React.useState(() => seed.owner);
const [admins, setAdmins] = React.useState(() => seed.admins);
const [toast, setToast] = React.useState('');
const [loadError, setLoadError] = React.useState(false);
const load = React.useCallback(() => {
if (!topic) return;
const cached = readCachedData('group-members:' + topic);
if (cached) { const d = deriveOwnerAndAdmins(cached); setOwner(d.owner); setAdmins(d.admins); }
// Fetch the unfiltered member list rather than {role:'admin'} + a separate per-user profile lookup
// for the owner -- GET /users/{id}/profile 400s ("malformed request", field "user_id") when {id} is
// the caller's own uid, which broke this whenever the viewer is themselves the group's owner.
getGroupMembers(topic).then(data => {
const items = data.items || [];
writeCachedData('group-members:' + topic, items);
const d = deriveOwnerAndAdmins(items);
setOwner(d.owner);
setAdmins(d.admins);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
React.useEffect(() => { load(); }, [load]);
const remove = userId => {
deleteGroupAdmin(topic, userId).then(() => { setToast('管理员已移除'); load(); })
.catch(err => onToast(err.message || '操作失败'));
};
return <div className="group-page">
<Header title="设置群管理员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-admin-add', topic)} />
<main className="group-admin-page">
<aside><Icon>info_outline</Icon><p>管理员可协助审批进群申请、管理公告和群成员。最多设置 10 名管理员。</p></aside>
{owner ? <><h2>群主</h2><article><Avatar item={owner} /><span><strong>{owner.display_name}</strong><small>群主</small></span></article></> : null}
<h2>管理员 {admins ? admins.length : 0}/10</h2>
{admins === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> :
admins.length ? admins.map(item => <article key={item.user_id}>
<Avatar item={item} /><span><strong>{item.display_name}</strong><small>管理员</small></span>
<button type="button" onClick={() => remove(item.user_id)}>移除</button>
</article>) : <div className="group-inline-empty">暂无管理员</div>}
</main>
<CenterToast message={toast} onDone={() => setToast('')} />
</div>;
}
// Shared alphabetically-grouped multi-select picker for admin-add / mute-add (same visual component,
// three purposes, per docs/images/添加管理员.png vs .../添加管理员-设置成功(1).png which turned out to
// actually be the mute-add variant of the identical layout).
function MemberPicker({type, topic, onToast}) {
const mute = type === 'mute-add';
const back = mute ? 'group-muted' : 'group-admins';
const [members, setMembers] = React.useState(null);
const [selected, setSelected] = React.useState([]);
const [query, setQuery] = React.useState('');
const [toast, setToast] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic, mute ? {muteEligible: true} : {eligibleRole: 'admin'})
.then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
.catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic, mute]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
const done = () => {
if (!selected.length || busy) return;
setBusy(true);
const call = mute ? muteGroupMembers(topic, selected.map(user_id => ({user_id}))) : addGroupAdmins(topic, selected);
call.then(result => {
// 两个批量接口的响应结构不一样,不能用同一套判定:
// - 添加管理员 POST .../admins/batch 返回 succeeded[],每项带 ok/reason(im_http.go:5377-5392)
// - 添加禁言 POST .../mutes 返回 {topic, items},只有成功的才进 items,
// 非法 uid 会被静默 continue(im_http.go:5486-5488),根本没有 succeeded 字段
// 原来两条路都读 succeeded,禁言那条恒为 undefined → failed 恒为空 → 不管实际成没成都弹"设置成功"。
let failedCount;
if (mute) {
const done = (result && Array.isArray(result.items)) ? result.items.length : selected.length;
failedCount = Math.max(selected.length - done, 0);
} else {
failedCount = (result && Array.isArray(result.succeeded)) ? result.succeeded.filter(s => !s.ok).length : 0;
}
setToast(failedCount ? `成功设置 ${selected.length - failedCount} 人,${failedCount} 人设置失败` : '设置成功');
// 导航会让整棵子树重挂载、把 toast 一起销毁。原来 900ms 就跳走,而 CenterToast 的生命
// 周期是 1400ms —— 参考图里那个「设置成功」实际上只闪一下就被截断,而且从不在目标列表页出现。
window.setTimeout(() => navigate(back, topic), 1500);
}).catch(err => onToast(err.message || '设置失败')).finally(() => setBusy(false));
};
return <div className="group-page">
<Header title={mute ? '添加禁言成员' : '添加管理员'} back={back} backTopic={topic}
right={`完成${selected.length ? `(${selected.length})` : ''}`} onRight={selected.length ? done : undefined} />
<Search value={query} onChange={setQuery} placeholder="搜索群成员" />
<main className="group-member-picker">
{members === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> : groups.map(([letter, list]) => <React.Fragment key={letter}>
<div className="group-section-title">{letter}</div>
{list.map(item => <button type="button" key={item.user_id} onClick={() => toggle(item.user_id)}>
<Avatar item={item} /><span><strong>{item.group_nickname || item.display_name}</strong></span>
<i className={'group-check' + (selected.includes(item.user_id) ? ' checked' : '')}>
{selected.includes(item.user_id) ? <Icon>check</Icon> : null}
</i>
</button>)}
</React.Fragment>)}
</main>
<CenterToast message={toast} onDone={() => setToast('')} />
</div>;
}
function Muted({topic, onToast}) {
const [items, setItems] = React.useState(() => (topic && readCachedData('group-muted:' + topic)) || null);
// groupGovernancePayload's mute list only ever returns a flat user_id (no display_name field --
// confirmed against the real backend struct), so without this it rendered raw ids like "usr8x2kf9"
// instead of names. The full member roster already carries group_nickname/display_name for
// everyone still in the group, and fetching it here is one extra request for the whole list
// (not per-row), unlike JoinRequests' per-item getUserProfile lookup.
const [names, setNames] = React.useState({});
const [avatars, setAvatars] = React.useState({});
const [loadError, setLoadError] = React.useState(false);
const load = React.useCallback(() => {
if (!topic) return;
getGroupMutes(topic).then(data => {
const list = data.items || [];
writeCachedData('group-muted:' + topic, list);
setItems(list);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
getGroupMembers(topic).then(data => {
const map = {};
const avatarMap = {};
(data.items || []).forEach(m => {
map[m.user_id] = m.group_nickname || m.display_name || m.user_id;
if (m.avatar_file_id) avatarMap[m.user_id] = {avatar_file_id: m.avatar_file_id};
});
setNames(map);
setAvatars(avatarMap);
}).catch(() => {});
}, [topic]);
React.useEffect(() => { setItems((topic && readCachedData('group-muted:' + topic)) || null); load(); }, [topic, load]);
const unmute = userId => {
unmuteGroupMember(topic, userId).then(() => { onToast('已解除禁言'); load(); })
.catch(err => onToast(err.message || '操作失败'));
};
return <div className="group-page">
<Header title="禁言的群成员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-mute-add', topic)} />
<main className="group-muted-list">
{items === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> :
items.length ? items.map(item => <article key={item.user_id}>
{/* 必须传 icon="person":Avatar 的兜底图标默认是 'group',禁言的是人不是群,
原来每一行都画成群组图标。getGroupMutes 本身不返回 avatar_file_id
(im_http.go:7083-7096),但同组件已经为了解析显示名拉过整份成员表,顺带把头像也带上。 */}
<Avatar item={avatars[item.user_id] || {}} icon="person" /><span><strong>{names[item.user_id] || item.user_id}</strong><small>{item.expires_at ? `禁言至 ${shortDate(item.expires_at)}` : '永久禁言'}</small></span>
<button type="button" onClick={() => unmute(item.user_id)}>解除</button>
</article>) : <div className="group-inline-empty">暂无禁言成员</div>}
{/* "以上成员不允许发言"在参考图里是列表下方的常驻灰色注脚,不是空状态文案。
原来把它当空态用,结果有禁言成员时这句话消失、一个都没有时反而显示它,语义正好反了。 */}
{items && items.length ? <p className="group-inline-note">以上成员不允许发言</p> : null}
</main>
</div>;
}
function OwnerSelect({topic, onToast}) {
const [members, setMembers] = React.useState(null);
const [query, setQuery] = React.useState('');
const [target, setTarget] = React.useState(null);
// 后端 serveIMGroupOwnerTransfer 只认 {target_user_id, version},没有任何二次验证/action_token
// 机制(跟青少年模式关闭那种真的会被服务端拒绝的场景不一样)。群主转让同样不可逆、后果同样
// 重大,照抄本项目已有的处理这类"后端没有二次验证但操作本身很危险"场景的方式——见
// personal-settings-view.jsx 的 DeleteAccountConfirm:加一步前端自己的密码输入门槛,不追加到
// 请求体、不指望服务端校验它,只是让"手滑点确定"变成需要多一步真实操作才会发生的事。
const [passwordStep, setPasswordStep] = React.useState(false);
const [password, setPassword] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic, {eligibleRole: 'owner'}).then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
.catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin');
const reset = () => { setTarget(null); setPasswordStep(false); setPassword(''); };
const confirm = () => {
if (!password) { onToast('请输入密码以确认'); return; }
if (busy || !target) return;
setBusy(true);
transferGroupOwner(topic, target.user_id).then(() => { onToast('群主已转让'); navigate('group-governance', topic); })
.catch(err => onToast(err.message || '转让失败')).finally(() => setBusy(false));
};
const row = item => <article key={item.user_id} role="button" tabIndex={0} onClick={() => setTarget(item)}>
<Avatar item={item} /><span><strong>{item.group_nickname || item.display_name}</strong></span>
</article>;
return <div className="group-page">
<Header title="选择新群主" back="group-governance" backTopic={topic} />
<Search value={query} onChange={setQuery} placeholder="搜索" />
<main className="group-request-list">
{members === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> : <>
{admins.length ? <><div className="group-section-title">管理员</div>{admins.map(row)}</> : null}
{others.length ? <><div className="group-section-title">群成员</div>{others.map(row)}</> : null}
</>}
</main>
{target && !passwordStep ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
<h2>选择新群主</h2>
<p>{target.group_nickname || target.display_name}成为新群主,确定后你将立即失去群主身份</p>
<button type="button" className="danger" onClick={() => setPasswordStep(true)}>确定</button>
<button type="button" onClick={reset}>取消</button>
</section></div> : null}
{target && passwordStep ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
<h2>输入密码确认转让</h2>
<p>请输入登录密码以确认将群主转让给{target.group_nickname || target.display_name}(该操作立即生效,不可撤销)。</p>
<input type="password" autoFocus className="group-password-input" value={password}
onChange={e => setPassword(e.target.value)} placeholder="登录密码" />
<button type="button" className="danger" disabled={busy} onClick={confirm}>{busy ? '处理中…' : '确定'}</button>
<button type="button" onClick={reset}>取消</button>
</section></div> : null}
</div>;
}
const FILE_TYPE_META = {
image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'],
document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']
};
function GroupFiles({topic, tinode, onToast}) {
// 群文件搜索走服务端 getGroupFiles({query}),同样防抖。
const [queryInput, setQueryInput] = React.useState('');
const query = useDebouncedValue(queryInput, 300);
// Only the unfiltered (query === '') result is cached -- same rule GroupList already follows, so a
// plain revisit never shows a stale search result under a fresh empty query.
const [files, setFiles] = React.useState(() => (topic && readCachedData('group-files:' + topic)) || null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [uploading, setUploading] = React.useState(null);
const [loadError, setLoadError] = React.useState(false);
// groupFilePayload 只给 uploader_id、不给昵称(im_http.go:6532-6559),原来直接把裸 user_id
// 显示在"来自"位置。跟 Muted 页一样,拉一次成员表交叉引用出可读的名字。
const [uploaderNames, setUploaderNames] = React.useState({});
const inputRefs = {file: React.useRef(null), image: React.useRef(null), video: React.useRef(null)};
const load = React.useCallback(() => {
if (!topic) return;
getGroupFiles(topic, {query}).then(data => {
const items = data.items || [];
if (!query) writeCachedData('group-files:' + topic, items);
setFiles(items);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic, query]);
React.useEffect(() => { load(); }, [load]);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic).then(data => {
const map = {};
(data.items || []).forEach(m => { map[m.user_id] = m.group_nickname || m.display_name || m.user_id; });
setUploaderNames(map);
}).catch(() => {});
}, [topic]);
const usedBytes = (files || []).reduce((sum, f) => sum + (f.file_size || 0), 0);
const pick = kind => { setMenuOpen(false); inputRefs[kind].current && inputRefs[kind].current.click(); };
const onFile = e => {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file || !tinode) return;
setUploading({name: file.name, progress: 0});
uploadLargeFile(tinode, file, frac => setUploading(u => u ? {...u, progress: Math.round(frac * 100)} : u))
.then(({fileId}) => addGroupFile(topic, {fileId, fileName: file.name, mimeType: file.type, fileSize: file.size}))
.then(() => { setUploading(null); load(); })
.catch(err => { setUploading(null); onToast(err.message || '上传失败'); });
};
const remove = fileId => {
deleteGroupFile(topic, fileId).then(() => load()).catch(err => onToast(err.message || '删除失败'));
};
const gb = n => (n / (1024 ** 3)).toFixed(2);
return <div className="group-page">
<Header title="群文件" back="group-settings" backTopic={topic}
right={<Icon>add</Icon>} onRight={() => setMenuOpen(v => !v)} />
<input ref={inputRefs.file} type="file" style={{display: 'none'}} onChange={onFile} />
<input ref={inputRefs.image} type="file" accept="image/*" style={{display: 'none'}} onChange={onFile} />
<input ref={inputRefs.video} type="file" accept="video/*" style={{display: 'none'}} onChange={onFile} />
{menuOpen ? <div className="group-upload-menu">
<button type="button" onClick={() => pick('file')}><Icon>upload_file</Icon>上传文件</button>
<button type="button" onClick={() => pick('image')}><Icon>image</Icon>上传照片</button>
<button type="button" onClick={() => pick('video')}><Icon>play_circle_outline</Icon>上传视频</button>
</div> : null}
<Search value={queryInput} onChange={setQueryInput} placeholder="搜索文件名或者上传者" />
<p className="group-inline-hint">共{(files || []).length}个文件(已使用{gb(usedBytes)}GB/10GB)</p>
<main className="group-file-list">
{uploading ? <article className="uploading">
<span className="group-file-type" style={{backgroundColor: '#8a8f98'}}><Icon>upload_file</Icon></span>
<div><strong>{uploading.name}</strong><small>上传中 {uploading.progress}%</small><progress value={uploading.progress} max="100" /></div>
</article> : null}
{files === null ? <p className="group-inline-empty">{loadError ? '文件列表加载失败' : '加载中…'}</p> :
files.map(file => {
const [icon, color] = FILE_TYPE_META[file.file_type] || FILE_TYPE_META.file;
return <article key={file.group_file_id}>
<span className="group-file-type" style={{backgroundColor: color}}><Icon>{icon}</Icon></span>
<div><strong>{file.file_name || '未命名文件'}</strong><small>来自 {uploaderNames[file.uploader_id] || file.uploader_id} · {bytesToGB(file.file_size)}</small></div>
<button type="button" onClick={() => remove(file.group_file_id)}><Icon>delete_outline</Icon></button>
</article>;
})}
</main>
</div>;
}
// 名字叫 bytesToGB 却只输出到 MB —— 1.4GB 的文件会显示成 "1433.6 MB"。补上 GB 档。
function bytesToGB(bytes) {
if (!bytes) return '0 KB';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// 标题"群昵称"是参考图 设置昵称(1).png 本身的用词(不是笔误)——虽然编辑的其实是 group.name
// 这个所有人都看得到的真实群名,跟 MyNicknameEditor("我在本群的昵称",个人在群内的显示名,
// 完全不同的字段)概念上容易混,但这是设计稿明确画出来的文案,不是代码猜的,不要改。
function GroupNameEditor({topic, onToast}) {
const [group, setGroup] = React.useState(null);
const [value, setValue] = React.useState('');
React.useEffect(() => { if (topic) getGroup(topic).then(g => { setGroup(g); setValue(g.name); }).catch(() => onToast('加载失败')); }, [topic]);
const save = () => {
if (!value.trim()) return;
updateGroup(topic, {name: value.trim(), version: group ? group.version : 0}).then(() => {
onToast('保存成功'); window.setTimeout(() => navigate('group-profile', topic), 600);
}).catch(err => onToast(err.message || '保存失败'));
};
return <div className="group-page">
<Header title="群昵称" back="group-profile" backTopic={topic} right="完成" onRight={save} />
<main className="group-edit-text"><input autoFocus value={value} maxLength={11} onChange={e => setValue(e.target.value)} /><small>{value.length}/11</small></main>
</div>;
}
function GroupDescriptionEditor({topic, onToast}) {
const [group, setGroup] = React.useState(null);
const [value, setValue] = React.useState('');
React.useEffect(() => { if (topic) getGroup(topic).then(g => { setGroup(g); setValue(g.description || ''); }).catch(() => onToast('加载失败')); }, [topic]);
const publish = () => {
updateGroup(topic, {description: value.trim(), version: group ? group.version : 0}).then(() => {
onToast('保存成功'); window.setTimeout(() => navigate('group-profile', topic), 600);
}).catch(err => onToast(err.message || '保存失败'));
};
return <div className="group-page">
<Header title="群简介" back="group-profile" backTopic={topic} right="发布" onRight={publish} />
<main className="group-edit-text">
{/* 上限跟后端硬限对齐(im_http.go:4667 超过 200 rune 直接 400 field=description)。
参考图标的是 0/225,但那个数字后端不接受——按 225 放行会让用户写满再被服务端打回。 */}
<textarea autoFocus value={value} maxLength={200} placeholder="请输入群简介内容" onChange={e => setValue(e.target.value)} />
<small>{value.length}/200</small>
</main>
</div>;
}
// "我在本群的昵称" has no dedicated reference screenshot (see plan notes) -- placed in GroupSettings,
// governed by the real member_invite... no, by the group_nickname field on groupMemberPayload, gated by
// the "群成员修改昵称" concept the images described (server-side enforcement, not re-checked client-side).
function MyNicknameEditor({topic, onToast}) {
const [value, setValue] = React.useState('');
// getGroupMembers' list items never include group_nickname (confirmed live) -- only
// GET .../members/me/profile actually returns it, same fix as GroupProfile's preview row above.
React.useEffect(() => {
if (!topic) return;
getMyGroupProfile(topic).then(data => setValue(data.nickname || '')).catch(() => {});
}, [topic]);
const save = () => {
updateMyGroupProfile(topic, value.trim(), 0).then(() => {
onToast('保存成功'); window.setTimeout(() => navigate('group-settings', topic), 600);
}).catch(err => onToast(err.message || '保存失败'));
};
return <div className="group-page">
<Header title="我在本群的昵称" back="group-settings" backTopic={topic} right="完成" onRight={save} />
<main className="group-edit-text"><input autoFocus value={value} maxLength={32} onChange={e => setValue(e.target.value)} /><small>群昵称仅在当前群内显示。</small></main>
</div>;
}
export default function GroupManagementView({view, hashParams, myUserId, tinode, onTopicSelected}) {
const [toast, setToast] = React.useState('');
const topic = hashParams && hashParams.topic;
// This container remounts on every navigation step inside the feature (see `key={view}` in
// sidepanel-view.jsx), and Announcement's edit/publish UI is gated on this role -- uncached, that
// gate visibly flickers off and back on every time the announcement page (or any sibling page) is
// opened. Also warms the same shared 'group-members:'+topic entry GroupSettings/AdminList use.
const roleCacheKey = topic && myUserId ? `group-my-role:${topic}:${myUserId}` : null;
const [myRole, setMyRole] = React.useState(() => (roleCacheKey && readCachedData(roleCacheKey)) || null);
React.useEffect(() => {
if (!topic || !myUserId) { setMyRole(null); return; }
const key = `group-my-role:${topic}:${myUserId}`;
setMyRole(readCachedData(key) || null);
getGroupMembers(topic).then(data => {
const items = data.items || [];
writeCachedData('group-members:' + topic, items);
const me = items.find(m => m.user_id === myUserId);
const role = me ? me.role : null;
writeCachedData(key, role);
setMyRole(role);
}).catch(() => {});
}, [topic, myUserId]);
let content;
if (view === 'group-list' || view === 'group-empty') content = <GroupList tinode={tinode} 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 topic={topic} onToast={setToast} />;
else if (view === 'group-announcement-detail') content = <Announcement topic={topic} myRole={myRole} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />;
else if (view === 'group-settings') content = <GroupSettings topic={topic} myUserId={myUserId} tinode={tinode} onToast={setToast} />;
else if (view === 'group-profile') content = <GroupProfile topic={topic} myUserId={myUserId} onToast={setToast} />;
else if (view === 'group-avatar') content = <AvatarSheet topic={topic} myUserId={myUserId} tinode={tinode} onToast={setToast} />;
else if (view === 'group-nickname') content = <GroupNameEditor topic={topic} onToast={setToast} />;
else if (view === 'group-description') content = <GroupDescriptionEditor topic={topic} onToast={setToast} />;
else if (view === 'group-my-nickname') content = <MyNicknameEditor topic={topic} myUserId={myUserId} onToast={setToast} />;
else if (view === 'group-qr') content = <GroupQr topic={topic} onToast={setToast} />;
else if (view === 'group-governance') content = <Governance topic={topic} tinode={tinode} onToast={setToast} />;
else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = <AdminList topic={topic} onToast={setToast} />;
else if (view === 'group-admin-add') content = <MemberPicker type="admin-add" topic={topic} onToast={setToast} />;
else if (view === 'group-mute-add') content = <MemberPicker type="mute-add" topic={topic} onToast={setToast} />;
else if (view === 'group-muted') content = <Muted topic={topic} onToast={setToast} />;
else if (['group-owner-select', 'group-owner-confirm'].includes(view)) content = <OwnerSelect topic={topic} onToast={setToast} />;
else content = <GroupFiles topic={topic} tinode={tinode} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}