- .people-entry-list > button 是4列grid,最后一列专留给箭头图标,但"新朋友"未处理申请数为0/ "群组"/"文件助手"/"系统消息"这几个入口没有badge时,JSX只渲染3个真实子元素,grid自动布局 把箭头挤进第3列——实测空出623px空白,图标紧贴文字,完全不是贴右对齐效果。改成显式 grid-column:4 钉住箭头位置,不再依赖前面到底渲染了几个兄弟节点。 - AdminList移除管理员后紧接着的load()重新查询命中了服务端对这个查询形状的过期缓存,界面上 刚移除的管理员会短暂"复活"。改成移除成功后直接用已确认生效的结果本地更新state+patch共享 缓存,不再指望立即重新查询能反映刚发生的变更(这个缓存问题本身是后端"新增7"已记录的问题, 重新查询本身没有办法绕过,前端这次做的是不依赖它)。 均已用真实DOM结构/computed style确认修复生效。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1176 lines
78 KiB
React
1176 lines
78 KiB
React
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, readGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
|
||
deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner,
|
||
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile,
|
||
getGroupMemberProfile, fetchAllPages
|
||
} 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-members', '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, fromChat, 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.
|
||
// `fromChat`/`backToChat` mirror Announcement's handling of the exact same two-entry-point
|
||
// ambiguity above -- without it, returning from a click on the in-chat banner always landed on
|
||
// "group-settings" (closing the open conversation) instead of back to the chat itself.
|
||
const backToChat = React.useCallback(() => {
|
||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
|
||
}, [topic]);
|
||
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={fromChat ? undefined : 'group-governance'} backTopic={topic}
|
||
onBack={fromChat ? backToChat : undefined} />
|
||
<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));
|
||
};
|
||
// 6.9 要求"记录已读状态",但这个接口(POST .../read)之前完全没有调用点——每次打开公告详情页
|
||
// (无论从群设置点进来还是从聊天顶部横幅点进来)都视为"已读",用ref去重避免同一条公告在同一次
|
||
// 页面停留内重复上报。静默失败:已读打点不是这个页面的主要功能,不应该在失败时打断用户。
|
||
const readReportedFor = React.useRef(null);
|
||
React.useEffect(() => {
|
||
const id = data && data.announcement_id;
|
||
if (!id || !topic || readReportedFor.current === id) return;
|
||
readReportedFor.current = id;
|
||
readGroupAnnouncement(topic, id).catch(() => {});
|
||
}, [topic, data]);
|
||
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:"群成员"标题行右侧是可点的"查看{N}群成员 >",现在真的有
|
||
落地页了(GroupMembers,复用 MemberPicker 同款字母分组/搜索,只读不可多选)。参考图里最后
|
||
一格的"删除"(移除成员)没有做——那是一个独立的、需要产品先确认交互细节的成员管理功能,
|
||
不属于这条"看不到全部成员"缺口本身。 */}
|
||
<section className="group-member-section">
|
||
<button type="button" className="group-member-section-title" onClick={() => navigate('group-members', topic)}>
|
||
<strong>群成员</strong><small>共 {group.member_count} 位成员</small><Icon>chevron_right</Icon>
|
||
</button>
|
||
<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>
|
||
{/* Swapping to a bare <div> when !canEdit used to drop every .group-profile row rule (all of
|
||
them are scoped to > button/> label) -- same fix as the avatar row just above: keep
|
||
<button>, gate it with disabled like that row already does. */}
|
||
<button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-nickname', topic)}>
|
||
<span>群名称</span><small>{group.name}</small><Icon>chevron_right</Icon>
|
||
</button>
|
||
<button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-description', topic)}>
|
||
<span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small><Icon>chevron_right</Icon>
|
||
</button>
|
||
<button type="button" className="group-profile-qr" onClick={() => navigate('group-qr', topic)}><span>群二维码</span><Icon>qr_code</Icon><Icon>chevron_right</Icon></button>
|
||
<button type="button" onClick={() => navigate('group-members', topic)}><span>群成员</span><small>共{group.member_count}位成员</small><Icon>chevron_right</Icon></button>
|
||
</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, myRole, 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) => {
|
||
// GET .../governance does return a real `version` (confirmed live) -- omitting it here isn't
|
||
// just "no version sent", the server-side UPDATE for this endpoint skips its optimistic-lock
|
||
// WHERE clause entirely whenever version<=0, so two admins toggling different switches at
|
||
// nearly the same time would have the second write silently clobber the first with no conflict
|
||
// signal. Sending the version this page already has on hand costs nothing and closes that gap.
|
||
updateGroupGovernance(topic, {[key]: value, version: gov ? gov.version : 0}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
|
||
.catch(err => onToast(err.message || '设置失败'));
|
||
};
|
||
const dissolve = () => {
|
||
// Backend already refuses to actually destroy the group for a non-owner (the same native
|
||
// {del hard:true} call it silently downgrades into "remove me from this group" instead) --
|
||
// but that response is indistinguishable from a real success, so without this gate a member
|
||
// who somehow lands on this page got told "群聊已解散" when they'd really just left it.
|
||
if (myRole !== 'owner') return;
|
||
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">
|
||
<section>
|
||
<button type="button" onClick={() => navigate('group-avatar', topic)}><span>设置群头像</span><Icon>chevron_right</Icon></button>
|
||
</section>
|
||
<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 || myRole !== 'owner'} 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.
|
||
// Members cap out at 30/page against a 128-member group limit -- an admin sitting past page 1
|
||
// used to vanish from this list entirely rather than the list just being incomplete.
|
||
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(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 succeeding doesn't mean a follow-up load() will show it -- getGroupMembers
|
||
// is backed by a server-side cache that this exact query shape can keep serving stale for a
|
||
// while (confirmed live: an unfiltered re-query right after removal still showed the removed
|
||
// admin, while a query with different params the session hadn't used yet returned correctly).
|
||
// Patch the already-known-correct result into local state and the shared cache directly
|
||
// instead of trusting a re-fetch to reflect what was just confirmed to have happened.
|
||
deleteGroupAdmin(topic, userId).then(() => {
|
||
setToast('管理员已移除');
|
||
setAdmins(prev => prev ? prev.filter(a => a.user_id !== userId) : prev);
|
||
const cached = readCachedData('group-members:' + topic);
|
||
if (cached) writeCachedData('group-members:' + topic, cached.map(m => m.user_id === userId ? {...m, role: 'member'} : m));
|
||
}).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.group_nickname || 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.group_nickname || 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>;
|
||
}
|
||
|
||
// 参考图 群聊设置.png 里"群成员"标题行是可点的("查看{N}群成员 >"),但 GroupSettings 一直没有
|
||
// 对应的落地页——群成员超过预览网格的9人就完全看不到其余成员。复用 MemberPicker 已经在用的
|
||
// 字母分组+搜索逻辑,去掉多选/提交,只读展示每个人的角色。
|
||
function GroupMembers({topic, onToast}) {
|
||
const [members, setMembers] = React.useState(() => (topic && readCachedData('group-members:' + topic)) || null);
|
||
const [query, setQuery] = React.useState('');
|
||
const [loadError, setLoadError] = React.useState(false);
|
||
React.useEffect(() => {
|
||
if (!topic) return;
|
||
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => {
|
||
writeCachedData('group-members:' + topic, items);
|
||
setMembers(items);
|
||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||
}, [topic]);
|
||
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
|
||
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
|
||
const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
|
||
return <div className="group-page">
|
||
<Header title="群成员" back="group-settings" backTopic={topic} />
|
||
<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={() => navigateToProfile(topic, item.user_id, item)}>
|
||
<Avatar item={item} icon="person" /><span><strong>{item.group_nickname || item.display_name}</strong>{roleLabel(item.role) ? <small>{roleLabel(item.role)}</small> : null}</span>
|
||
</button>)}
|
||
</React.Fragment>)}
|
||
</main>
|
||
</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;
|
||
fetchAllPages(cursor => getGroupMembers(topic, {cursor, ...(mute ? {muteEligible: true} : {eligibleRole: 'admin'})}))
|
||
.then(items => setMembers(items.filter(m => m.eligible !== false)))
|
||
.catch(() => { onToast('加载失败'); setLoadError(true); });
|
||
}, [topic, mute]);
|
||
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
|
||
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;
|
||
fetchAllPages(cursor => getGroupMutes(topic, cursor)).then(list => {
|
||
writeCachedData('group-muted:' + topic, list);
|
||
setItems(list);
|
||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => {
|
||
const map = {};
|
||
const avatarMap = {};
|
||
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;
|
||
fetchAllPages(cursor => getGroupMembers(topic, {cursor, eligibleRole: 'owner'}))
|
||
.then(items => setMembers(items.filter(m => m.eligible !== false)))
|
||
.catch(() => { onToast('加载失败'); setLoadError(true); });
|
||
}, [topic]);
|
||
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
|
||
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's `version` param was never supplied here, so the optimistic-lock WHERE
|
||
// clause on the backend's UPDATE was silently skipped (version<=0) -- this is the single most
|
||
// consequential owner-transfer call in the app having zero protection against a concurrent
|
||
// transfer. Fetch the live group version right before the irreversible call rather than trusting
|
||
// any value this page might have cached earlier in its lifetime.
|
||
getGroup(topic).then(group => transferGroupOwner(topic, target.user_id, group.version))
|
||
.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}) {
|
||
const [queryInput, setQueryInput] = React.useState('');
|
||
const query = useDebouncedValue(queryInput, 300);
|
||
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)};
|
||
// 搜索框文案承诺"搜索文件名或者上传者",但服务端 ListFiles 的 `q` 只匹配文件名(上传者昵称
|
||
// 这类展示用的名字根本不在服务端索引里,是前端自己拿 getGroupMembers 交叉查出来的)——如果把
|
||
// query 传给服务端,按上传者名字搜索永远是静默的空结果。改成服务端只拿全量列表(本来文件数量
|
||
// 就不大,也没有任何"加载更多"分页UI),文件名/上传者两个维度都在本地用已有的 uploaderNames
|
||
// 表一起过滤,让搜索框真正兑现自己的文案。
|
||
const load = React.useCallback(() => {
|
||
if (!topic) return;
|
||
getGroupFiles(topic, {}).then(data => {
|
||
const items = data.items || [];
|
||
writeCachedData('group-files:' + topic, items);
|
||
setFiles(items);
|
||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||
}, [topic]);
|
||
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 visibleFiles = React.useMemo(() => {
|
||
const all = files || [];
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return all;
|
||
return all.filter(f => (f.file_name || '').toLowerCase().includes(q) ||
|
||
(uploaderNames[f.uploader_id] || '').toLowerCase().includes(q));
|
||
}, [files, query, uploaderNames]);
|
||
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="搜索文件名或者上传者" />
|
||
{/* "/10GB" 分母曾经是前端编的——群文件相关接口(含治理/设置)全仓库没有任何配额字段下发,
|
||
显示一个无依据的上限数字比不显示更容易误导人,改成只报告已用量。 */}
|
||
<p className="group-inline-hint">共{(files || []).length}个文件(已使用{gb(usedBytes)}GB)</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> :
|
||
visibleFiles.length === 0 && query.trim() ? <p className="group-inline-empty">未找到匹配的文件</p> :
|
||
visibleFiles.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);
|
||
// Used to look this up by paginating the full member list and finding myUserId in it (30/page
|
||
// against a 128-member cap) -- a real owner/admin sitting past page 1 read back as `null`,
|
||
// silently hiding every governance affordance gated on this value. getGroup's own `my_role`
|
||
// (already relied on elsewhere, e.g. the disband-group guard above) isn't paginated at all --
|
||
// it's always exactly "me", so this no longer needs the member list at all.
|
||
getGroup(topic).then(group => {
|
||
const role = group ? group.my_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} fromChat={hashParams && hashParams.from === 'chat'} 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-members') content = <GroupMembers topic={topic} onToast={setToast} />;
|
||
else if (view === 'group-qr') content = <GroupQr topic={topic} onToast={setToast} />;
|
||
else if (view === 'group-governance') content = <Governance topic={topic} myRole={myRole} 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('')} /></>;
|
||
}
|