Files
im_webapp/src/views/group-management-view.jsx
T
Your NameandClaude Opus 5 5a07bb4e3b 修复全页面走查发现的 P0 前端问题(第一批)
八个 agent 对 11 章逐页对照设计稿+后端源码后,先修其中"功能不可用/崩溃/谎报成功"级别的:

崩溃与数据错误
- content-library-view: Highlight 按 [start,len] 数组解构后端返回的 {start,end} 对象,
  对象不可迭代会直接抛 TypeError,任何文本搜索命中都让整个结果区渲染崩掉。服务端偏移还叠加
  两个问题(Go 字节下标 vs JS UTF-16;偏移基准是全文但 snippet 会被开窗截断),改为在展示
  文本上本地匹配关键词。
- content-library-view: firstAttachment 用 Drafty.attachments,SDK 里硬过滤 tp=='EX'
  只认文件附件,图片(IM)/视频(VD)/语音(AU) 一个都取不到——收藏缩略图、语音播放源、聊天记录
  图片视频宫格全部拿不到资源。改用 Drafty.entities 按类型分派(chat-message.jsx 已有先例)。
- message-center-view: 系统通知的机器码兜底会把真实数据吞掉(日报提醒的 summary 就是日期
  字符串 "2026-08-20",会议通知的 summary 是标题),改为查表命中才替换。
- message-center-view: 会话列表漏了 avatar 字段,每行都只能落到兜底图标;同文件
  frequentContacts() 本来就是对的,照它补上。

功能不可用
- auth-onboarding-view: 首次开启青少年模式是死路——设完密码丢弃了服务端返回的
  enable_action_token,从不调 enableYouthMode,用户走完短信验证+设密码后模式仍是关的。
  现在拿着这个 token 把最后一步做完。
- group-management-view: "投诉/举报"是个 onToast('暂未开放') 死桩,而 target_type='group'
  这条链路早就通了。接上真实举报页。
- tinode-web: 刷新/深链会被弹回首页。两个原因叠加:初始化白名单还是上游 Tinode 的
  ['cred','reset','register'];handleLoginSuccessful 无条件把 hash 改写成 workbench,
  刷新触发的自动登录也走这条。抽出 SIDE_PANEL_VIEWS 常量复用,并区分登录流程与自动登录。
- widgets/meta-message: deleted 分支从未实现,撤回一条消息后时间线上什么都不剩。

谎报成功 / 判定错误
- tinode-web + personal-settings-view: 改密码不返回 Promise,服务端拒绝时照样弹"密码已更新"。
- group-management-view: 禁言批量读 result.succeeded,但该接口返回的是 {topic, items},
  succeeded 恒为 undefined,不管成没成都弹"设置成功"。两个接口结构不同,分开判定。
- workplace-collaboration-view: 会议取消状态写成 'cancelled',后端是 'canceled' 单 l,
  会议取消后"取消会议"按钮还挂着。

样式与显示
- base.css: 删掉旧版 .organization-department 的 padding-left !important,它压过按层级算出的
  内联缩进,导致部门行全平、只有人员行缩进。
- base.css: 补 .youth-password-gate(原来 0 条规则),PIN 框缺 position:relative 和居中。
- security-support-view: shield_lock 不在经典 Material Icons 里(只有 Symbols),会显示成
  字面文本;.risk-info-page 也是 0 条规则,整页渲染成裸项目符号列表。改用 verified_user 并
  补上 .risk-dialog。
- contact-management-view: 好友资料页性别直接输出后端枚举,显示成 "male"。
- workplace-collaboration-view: 层级标注两条路径两种格式(树形深三层变"3级"、搜索硬传"三"),
  统一成一个 helper。

已记录不在前端修的后端缺口:recallMessage 的 version 只能传 1,因为所有消息读取接口
(messagePayload/messageSearchPayload/mediaMessagePayload/favoriteSnapshot)都不下发 version,
冲突错误里也不回传真实值,被编辑过的消息撤回必然失败。

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

964 lines
60 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
} from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
// Carries the group `topic` (and anything else already in the hash) across navigation, the same way
// contact-management-view.jsx threads `uid` -- these pages all need to know *which* group.
const navigate = (view, topic) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
HashNavigation.navigateTo(hash);
};
// 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for
// viewing a group member who isn't necessarily a friend -- that route keys off `uid`, not `topic`, so it
// can't go through this file's own `navigate` helper above without clobbering the wrong hash param.
const navigateToProfile = userId => HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
export const GROUP_MANAGEMENT_VIEWS = [
'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests',
'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail',
'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description',
'group-my-nickname', 'group-role-message', 'group-qr', 'group-governance', 'group-admins',
'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed',
'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files',
'group-file-upload'
];
// Same first-pinyin-letter grouping as contact-management-view.jsx's alphaInitial -- duplicated rather
// than shared, since that file's copy is unexported and this one is the only other place that needs it.
function alphaInitial(name) {
const ch = (name || '').trim()[0] || '#';
if (/[A-Za-z]/.test(ch)) {
return ch.toUpperCase();
}
if (/[一-龥]/.test(ch)) {
const letter = pinyin(ch, {pattern: 'first', toneType: 'none'});
return /[a-z]/i.test(letter) ? letter.toUpperCase() : '#';
}
return '#';
}
function groupBy(list, keyFn) {
const groups = {};
list.forEach(item => {
const key = keyFn(item);
(groups[key] || (groups[key] = [])).push(item);
});
return Object.keys(groups).sort().map(key => [key, groups[key]]);
}
function shortDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return `${d.getMonth() + 1}-${String(d.getDate()).padStart(2, '0')}`;
}
// -- Demo-only mock data below this line: still used by the untouched GroupChat/ChatHeader/SystemEvent
// -- trio (see comment above GroupChat). Those views are a standalone design reference showing message-
// -- timeline states -- the real group chat timeline is messages-view.jsx, not this file (same treatment
// -- Ch.3 gave direct-message-view.jsx). Nothing else in this file reads these constants any more.
const MEMBERS = [
{id: 'annie', name: '安妮', role: '管理员', avatar: 'img/bkg/p09.jpg'},
{id: 'lizi', name: '栗子来了', role: '成员', avatar: 'img/bkg/p15.jpg'},
{id: 'wang', name: '王珊珊', role: '成员', avatar: 'img/bkg/p14.jpg'},
{id: 'sun', name: '孙光洪', role: '成员', avatar: 'img/bkg/p03.jpg'},
{id: 'lin', name: '林间风', role: '成员', avatar: 'img/bkg/p18.jpg'}
];
const GROUPS_DEMO = {
owned: [{id: 'product', name: '产品研发群', count: 18, avatar: 'img/bkg/p22.jpg'}]
};
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'group-list', backTopic, right, onRight}) {
return <header className="group-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) 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({onToast}) {
// Only the unfiltered (query === '') result is worth caching -- caching a search result under the
// same key would show stale search results instead of the real list on the next plain visit.
const [lists, setLists] = React.useState(() => readCachedData('groups') || {owned: null, managed: null, joined: null});
const [loadErrors, setLoadErrors] = React.useState({owned: false, managed: false, joined: false});
const [category, setCategory] = React.useState('owned');
const [query, setQuery] = React.useState('');
const [creating, setCreating] = React.useState(false);
const [name, setName] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const reload = React.useCallback(() => {
['owned', 'managed', 'joined'].forEach(cat => {
getGroups({category: cat, query}).then(data => {
const items = data.items || [];
setLists(v => {
const next = {...v, [cat]: items};
if (!query) writeCachedData('groups', next);
return next;
});
}).catch(() => { onToast('加载失败'); setLoadErrors(v => ({...v, [cat]: true})); });
});
}, [query]);
React.useEffect(() => { reload(); }, [reload]);
const current = lists[category];
const submit = () => {
if (!name.trim() || submitting) return;
setSubmitting(true);
createGroup({name: name.trim()}).then(() => {
setCreating(false); setName(''); onToast('群聊创建成功'); reload();
}).catch(err => onToast(err.message || '创建失败')).finally(() => setSubmitting(false));
};
return <div className="group-page">
<Header title="群聊" back="address-book" right="创建" onRight={() => setCreating(true)} />
<Search value={query} onChange={setQuery} placeholder="搜索群聊" />
<nav className="group-tabs">
{[['owned', '我创建的'], ['managed', '我管理的'], ['joined', '我加入的']].map(([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>{group.name}</strong><small>{group.member_count} 位成员</small></span>
<Icon>chevron_right</Icon>
</button>) :
<div className="group-empty"><Icon>groups</Icon><strong>这里还没有群聊</strong>
<p>{category === 'owned' ? '创建一个群聊,邀请成员一起沟通' : '加入或被设置为管理员后会显示在这里'}</p>
<button type="button" onClick={() => setCreating(true)}>创建群聊</button>
</div>}
</main>
{creating ? <div className="group-modal-backdrop"><section className="group-modal">
<h2>创建群聊</h2>
<label>群名称<input autoFocus value={name} maxLength="30" onChange={event => setName(event.target.value)} placeholder="输入群名称" /></label>
<p>创建后可继续邀请成员和设置群资料。</p>
<footer>
<button type="button" onClick={() => setCreating(false)}>取消</button>
<button type="button" className="primary" disabled={!name.trim() || 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.
const backView = fromChat ? '' : 'group-settings';
React.useEffect(() => {
if (!topic) return;
const cached = readCachedData('group-announcement:' + topic);
if (cached) { setData(cached); setDraft(cached.content || ''); }
getLatestGroupAnnouncement(topic).then(d => {
writeCachedData('group-announcement:' + topic, d);
setData(d); setDraft(d.content || '');
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const publish = () => {
if (!draft.trim() || saving) return;
setSaving(true);
updateCurrentGroupAnnouncement(topic, draft.trim(), data ? data.version : 0).then(d => {
writeCachedData('group-announcement:' + topic, d);
setData(d); setEditing(false); onToast('群公告已发布');
}).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false));
};
if (!data) return <div className="group-page"><Header title="群公告" back={backView} backTopic={topic} /><main className="group-announcement-detail"><p className="group-inline-empty">{loadError ? '群公告加载失败' : '加载中…'}</p></main></div>;
return <div className="group-page">
<Header title="群公告" back={backView} backTopic={topic}
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(item.user_id)}>
<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} 人设置失败` : '设置成功');
window.setTimeout(() => navigate(back, topic), 900);
}).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 [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 = {};
(data.items || []).forEach(m => { map[m.user_id] = m.group_nickname || m.display_name || m.user_id; });
setNames(map);
}).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}>
<Avatar item={{}} /><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>}
</main>
</div>;
}
function OwnerSelect({topic, onToast}) {
const [members, setMembers] = React.useState(null);
const [query, setQuery] = React.useState('');
const [target, setTarget] = React.useState(null);
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 confirm = () => {
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 ? <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" disabled={busy} onClick={confirm}>确定</button>
<button type="button" onClick={() => setTarget(null)}>取消</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 [query, setQuery] = React.useState('');
// 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);
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]);
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={query} onChange={setQuery} 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>{bytesToGB(file.file_size)} · {file.uploader_id}</small></div>
<button type="button" onClick={() => remove(file.group_file_id)}><Icon>delete_outline</Icon></button>
</article>;
})}
</main>
</div>;
}
function bytesToGB(bytes) {
if (!bytes) return '0 KB';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
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 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('')} /></>;
}