八个 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>
964 lines
60 KiB
React
964 lines
60 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, 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('')} /></>;
|
||
}
|