第六轮深度测试:7个并行agent覆盖全部11章,修复21处前端问题

Ch1-2: 用户名注册邀请码错误改为友好文案(原为原始TypeError泄露给用户);
  企业注册页扫描图标补上点击反馈。
Ch3-4: 发名片消息补传typeHint(此前被后端误分类成text,搜索/历史摘要显示成JSON);
  文件卡片按mime大类区分图标颜色(此前所有文件都是同一个灰色通用图标);
  表情面板补上专属退格/发送按钮(浅蓝禁用/深蓝启用两态);订正一处与截图不符的代码注释。
Ch5-6: 群聊设置页"群成员"补上可点击的完整成员列表页(复用MemberPicker字母分组);
  群文件页去掉无依据的硬编码"/10GB"容量上限显示。
Ch7: 收藏点击图片/视频缩略图补上预览;"查找聊天记录-链接"tab补上定位回原消息;
  文件下载页返回入口按来源tab纠正;批量转发成功计数改为按目标会话去重
  (原公式混用"收藏条数"和"目标会话数"两个维度,混选多条收藏转发时可能显示负数);
  收藏/视频tab切换补上URL路由同步。
Ch8: topic.onInfo在通话期间补上事件转发(此前通话中"对方正在输入"和已读回执静默失效);
  主叫等待接听页补上"正在等待对方接受邀请…"文案;语音通话最小化胶囊的还原按钮移出
  拖拽区域(原来嵌在draggable内部,指针捕获可能吞掉点击);修复中等窗宽(641-750px)下
  本地摄像头预览遮挡"切换摄像头"按钮的CSS重叠。
Ch9: 部门树展开时跳过child_count=0的注定空请求;月报字数上限从硬编码1000改为读取
  后端真实的10000;发起会议补上"结束时间需晚于开始时间"的本地校验和更具体的报错;
  日报/月报统计接口补上department_id透传(此前包装函数注释断言后端会忽略,实际已支持)。
Ch10-11: URL风险检测补上g标志(原来一条消息只检查第一个链接,第二个链接哪怕是高危
  也不会被拦截);补上"安全教育"设置入口(原功能完整实现但零入口,需手改hash才能打开);
  修改密码按钮补上busy态反馈;青少年模式PIN页补上"忘记独立密码"入口(后端已支持重置,
  前端未接);"关于我们"补上"联系我们"行。

7个agent同时对全部11章存量后端问题条目逐一复核,结论详见待整理的后端问题清单更新
(Ch1-2发现一条P0级后端问题:验证码固定值"123456",已记录未修复,不在此commit修改
im_be仓库范围内)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-05 03:48:28 +08:00
co-authored by Claude Sonnet 5
parent d42591284f
commit f42b8536e5
19 changed files with 634 additions and 111 deletions
+79
View File
@@ -3770,6 +3770,34 @@ img.channel {
text-decoration: none;
}
#emoji-panel-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.8rem 0.6rem;
border-top: 1px solid var(--clr-shadow-panel-divider);
}
#emoji-panel-footer a {
display: flex;
align-items: center;
justify-content: center;
width: 2.4rem;
height: 2.4rem;
text-decoration: none;
color: var(--clr-text-tertiary);
}
/* 同一色相、不同深浅区分"发不出去"和"能发"两态,不是灰色/彩色那种对比——跟本项目其它地方
记录过的"发送按钮禁用态浅蓝、启用态深蓝"是同一条设计判断。 */
#emoji-panel-footer a.emoji-panel-send {
color: light-dark(#90caf9, #4a7ba6);
}
#emoji-panel-footer a.emoji-panel-send.active {
color: var(--clr-primary-accent);
}
.emoji-panel-empty {
width: 100%;
padding: 1rem;
@@ -4075,6 +4103,18 @@ img.channel {
white-space: nowrap;
}
.call-pill-row {
/* Wraps .call-pill and its restore button as plain flex siblings (row, shrink-to-content) so the
button can sit right next to the pill using ordinary flow instead of position:absolute --
#video-container is already an absolute-positioned flex column (base rule, for the fullscreen
video layout) and fighting that from a descendant with more absolute positioning turned out to
size unpredictably (a container with a mix of flex/absolute descendants doesn't reliably
shrink-wrap the way a single plain flex child does). This wrapper is that one simple child. */
display: flex;
align-items: center;
gap: 0.5rem;
}
.audio-minimized button.full-screen {
position: static;
width: 1.5rem;
@@ -4309,6 +4349,15 @@ video {
display: inline-block;
}
/* Plain status line under the name badge (not another pill) -- matches how
call-experience-view.jsx's own demo renders the same "正在等待对方接受邀请…" copy. */
.caller-card .caller-status {
margin-top: 0.5rem;
color: var(--clr-bright);
font-size: 0.9rem;
opacity: 0.8;
}
.call-party.peer .caller-name.inactive {
position: absolute;
font-size: larger;
@@ -5971,6 +6020,23 @@ div.toast.show {
font-size: 1.35rem;
}
/* Same left-side position/color as the plain (non-clickable) .auth-field-icon above -- this is
just that icon made clickable (企业注册's credit-code scan icon), so it shouldn't look any
different at rest, only respond to a tap. */
.auth-field-icon-button {
flex: none;
display: flex;
margin: 0 0 0 1rem;
padding: 0;
border: 0;
background: transparent;
color: #4091ff;
}
.auth-field-icon-button .material-icons {
font-size: 1.35rem;
}
.auth-inline-icon,
.auth-icon-button {
display: grid;
@@ -6676,6 +6742,10 @@ div.toast.show {
margin-top: 3rem;
}
.youth-password-gate .auth-text-button {
margin-top: 1.2rem;
}
.auth-pin-boxes {
display: grid;
grid-template-columns: repeat(4, 4rem);
@@ -12526,7 +12596,16 @@ div.toast.show {
border-radius: unset;
box-shadow: unset;
}
}
/* .call-party.self's fullscreen self-preview (bottom-right corner) overlaps #controls' centered
button row somewhere around 641px-750px: narrower than this and the row above already switches to
the cramped mobile layout (with its own bottom:8.5rem clearance built in below); wider and #controls
has enough room to stay clear of the preview on its own. 768px is comfortably past where the overlap
was verified to stop, and is a common breakpoint already used elsewhere in this file. Kept as its own
query (rather than folded into the 640px block above) since the rest of that block also restructures
#video-container/#controls into an entirely different mobile layout that shouldn't kick in this early. */
@media (max-width: 768px) {
.call-party.self {
bottom: 8.5rem;
border-radius: unset;
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -760,8 +760,10 @@ export const getDailyReport = reportId => imGet('/reports/daily/' + encodeURICom
export const updateDailyDraft = (reportId, content, version, attachments = []) =>
imPut('/reports/daily/' + encodeURIComponent(reportId) + '/draft', {content, attachments, version});
export const submitDailyReport = (reportId, version) => imPost('/reports/daily/' + encodeURIComponent(reportId) + '/submit', {version});
// department_id is also ignored here -- statistics are always tenant-wide.
export const getDailyStatistics = date => imGet('/reports/daily/statistics' + qs({date}));
// TenantDailyReportStats does honor department_id (reportMemberScopeWhere adds `m.department_id=?`
// when it's non-empty) -- confirmed against current im_be HEAD, this wasn't always true and an earlier
// comment here claimed the opposite.
export const getDailyStatistics = (date, departmentId = '') => imGet('/reports/daily/statistics' + qs({date, department_id: departmentId}));
export const sendDailyReminders = (date, userIds = []) => imPost('/reports/daily/reminders', {date, user_ids: userIds});
export const getMonthlyReports = ({scope = 'mine', fromMonth = '', toMonth = '', status = '', cursor = ''} = {}) =>
imGet('/reports/monthly' + qs({scope, from_month: fromMonth, to_month: toMonth, status, cursor}));
@@ -778,8 +780,8 @@ export const submitMonthlyReport = (reportId, version) => imPost('/reports/month
// manager-reopens-someone-else's-report capability on the backend.
export const reopenMonthlyReport = (reportId, version) =>
imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version});
// department_id is ignored here too -- statistics are always tenant-wide.
export const getMonthlyStatistics = month => imGet('/reports/monthly/statistics' + qs({month}));
// Same department_id support as getDailyStatistics (TenantMonthlyReportStats shares reportMemberScopeWhere).
export const getMonthlyStatistics = (month, departmentId = '') => imGet('/reports/monthly/statistics' + qs({month, department_id: departmentId}));
export const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config');
export const submitExecutiveMailboxMessage = body => imPost('/executive-mailbox/submissions', body);
+6 -3
View File
@@ -37,10 +37,12 @@ function Header({title, back = 'tenant'}) {
</header>;
}
function Field({icon, action, error, className = '', ...props}) {
function Field({icon, onIconClick, action, error, className = '', ...props}) {
return <div className={'auth-field-wrap ' + className}>
<div className={'auth-field' + (error ? ' invalid' : '')}>
{icon ? <Icon className="auth-field-icon">{icon}</Icon> : null}
{icon ? (onIconClick ?
<button type="button" className="auth-field-icon-button" onClick={onIconClick}><Icon>{icon}</Icon></button> :
<Icon className="auth-field-icon">{icon}</Icon>) : null}
<input {...props} />
{action}
</div>
@@ -237,7 +239,8 @@ function EnterpriseRegister() {
<FormAlert text={errors.form} />
<label className="auth-label">统一社会信用代码</label>
<Field value={creditCode} onChange={e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode} />
placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode}
onIconClick={() => setToast('扫码识别暂不可用,请手动输入')} />
<p className="auth-help">请填写营业执照上的统一社会信用代码</p>
<label className="auth-label">上传营业执照 <span>选填</span></label>
+34 -10
View File
@@ -39,7 +39,7 @@ const navigate = (view, params) => {
export const CONTENT_LIBRARY_VIEWS = [
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
'favorites-links', 'favorites-select', 'favorite-share', 'file-download',
'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download',
'file-downloading', 'file-downloaded', 'history-search', 'history-files',
'history-links', 'history-images', 'history-videos'
];
@@ -186,11 +186,11 @@ function FavoriteItem({item, onOpen, selecting, selected, onSelect}) {
</article>;
}
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links'};
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links', image: 'favorites-image', video: 'favorites-video'};
const TYPE_LABELS = [['all', '全部'], ['image', '图片'], ['video', '视频'], ['file', '文件'], ['voice', '语音'], ['text', '文本'], ['link', '链接']];
function Favorites({view, tinode, onToast}) {
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : 'all';
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : view === 'favorites-image' ? 'image' : view === 'favorites-video' ? 'video' : 'all';
const selecting = view === 'favorites-select';
const [type, setType] = React.useState(selecting ? 'file' : initialType);
// 输入值与实际发请求用的值分开:原来 query 直接进 load 的依赖数组,输入 6 个字的关键词就打
@@ -253,7 +253,7 @@ function Favorites({view, tinode, onToast}) {
}).catch(err => onToast(err.message || '删除失败'));
};
const openItem = item => {
if (item.type === 'file') { navigate('file-download', {favoriteId: item.favorite_id}); return; }
if (item.type === 'file') { navigate('file-download', {favoriteId: item.favorite_id, fromType: type}); return; }
if (item.type === 'link') {
const url = (item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text);
checkURL(url, 'favorite').then(res => {
@@ -262,6 +262,14 @@ function Favorites({view, tinode, onToast}) {
}).catch(() => onToast('链接安全校验失败'));
return;
}
if (item.type === 'image' || item.type === 'video') {
// item._thumbUrl is the real resolveAttachmentUrl() result (see the effect above) -- this
// backend has no separate scaled-down thumbnail concept, so the same URL used to render the
// grid cell is already the full attachment, safe to open directly.
if (item._thumbUrl) window.open(item._thumbUrl, '_blank', 'noopener,noreferrer');
else onToast('正在加载,请稍后重试');
return;
}
onToast('暂不支持预览此类收藏');
};
const list = items || [];
@@ -305,9 +313,18 @@ function FavoriteShare({hashParams, myUserId, chatList, onToast}) {
if (sending) return;
setSending(true);
forwardFavorites(ids, selected, comment.trim()).then(result => {
// `items` is one row per (favorite x target) pair once a target passes the permission check,
// or one bare row per target when it fails outright -- either way its length isn't `selected.length`
// (target count) whenever more than one favorite is being shared at once, so a plain
// `selected.length - failedItems` mixed the two units and could go negative. Group back to the
// "会话" granularity the toast actually claims to report: a target only counts as fully sent once
// every one of its own items succeeded.
const items = (result && result.items) || [];
const failedCount = items.filter(r => r.status !== 'sent').length;
onToast(failedCount ? `${selected.length - failedCount}/${selected.length} 个会话分享成功` : `已分享给 ${selected.length} 个会话`);
const successTargets = selected.filter(target => {
const forTarget = items.filter(r => r.target_topic === target);
return forTarget.length > 0 && forTarget.every(r => r.status === 'sent');
}).length;
onToast(successTargets === selected.length ? `已分享给 ${selected.length} 个会话` : `${successTargets}/${selected.length} 个会话分享成功`);
window.setTimeout(() => navigate('favorites-files'), 700);
}).catch(err => onToast(err.message || '分享失败')).finally(() => setSending(false));
};
@@ -346,12 +363,14 @@ function DownloadPage({hashParams, tinode, onToast}) {
const [state, setState] = React.useState('ready');
const [progress, setProgress] = React.useState(0);
const downloaderRef = React.useRef(null);
const {favoriteId, topic, seq} = hashParams || {};
const {favoriteId, topic, seq, fromType} = hashParams || {};
// Two distinct entry points share this route: Favorites (favoriteId set, Ch.7.1) and History's
// file tab (topic+seq set, Ch.7.12) -- "back" must return to whichever one actually opened this
// page, not unconditionally to favorites-files (which History never came from and has no topic
// param for anyway).
const backView = favoriteId ? 'favorites-files' : 'history-files';
// param for anyway). Within Favorites itself, `openItem` now also threads through the tab the user
// was actually on (`fromType`) -- opening a file from "全部" or "文件" both land here, and should
// each return to where they came from, not always to the "文件" tab regardless of origin.
const backView = favoriteId ? (TYPE_ROUTE[fromType] || 'favorites-files') : 'history-files';
const backParams = favoriteId ? undefined : {topic};
React.useEffect(() => {
const apply = content => {
@@ -575,7 +594,12 @@ function History({view, hashParams, myUserId, onToast}) {
</React.Fragment>;
})}</section> :
view === 'history-links' ? <section className="library-record-links">{list.map(item => <React.Fragment key={item.seq}>
<div className="library-record-header"><strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small></div>
{/* 需求7.14"点击时执行外链安全策略,并允许用户定位回原始消息"是两个独立动作——点链接本身
走 openLink(安全校验+开外链),定位回原消息复用其它tab都在用的 jump(seq),挂在头部
这一行上(sender+时间),跟 history-files 的头部保持视觉一致,只是这里多了可点击行为。 */}
<button type="button" className="library-record-header" onClick={() => jump(item.seq)}>
<strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small>
</button>
<LinkText url={item.url || plainTextOf(item.txt !== undefined ? {txt: item.txt, fmt: item.fmt, ent: item.ent} : undefined, item.text)}
onOpen={openLink} removed={item.availability === 'removed'} />
</React.Fragment>)}</section> :
+44 -9
View File
@@ -64,7 +64,7 @@ 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-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance', 'group-admins',
'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed',
'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files',
'group-file-upload'
@@ -455,14 +455,14 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
<span><strong>{group.name}</strong><small>{group.description || '群主很懒,还没有群简介'}</small></span>
<Icon>chevron_right</Icon>
</button>
{/* 参考图 docs/images/群聊设置.png:一行"群成员"标题(右侧带人数),下面是等宽网格,最后一格
是"添加"。这里刻意不把标题行右侧做成可点链接、也没有参考图里那个"删除"格——本分支没有
通用的群成员列表页,也没有移除成员的页面,与其跳到语义不对的页面或放一个点了没用的格子,
不如先只展示信息,缺口另行记录。 */}
{/* 参考图 docs/images/群聊设置.png:"群成员"标题行右侧是可点的"查看{N}群成员 >",现在真的有
落地页了(GroupMembers,复用 MemberPicker 同款字母分组/搜索,只读不可多选)。参考图里最后
一格的"删除"(移除成员)没有做——那是一个独立的、需要产品先确认交互细节的成员管理功能,
不属于这条"看不到全部成员"缺口本身。 */}
<section className="group-member-section">
<div className="group-member-section-title">
<strong>群成员</strong><small>共 {group.member_count} 位成员</small>
</div>
<button type="button" className="group-member-section-title" onClick={() => navigate('group-members', topic)}>
<strong>群成员</strong><small>共 {group.member_count} 位成员</small><Icon>chevron_right</Icon>
</button>
<div className="group-member-grid">
{members.slice(0, 9).map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(topic, item.user_id, item)}>
<Avatar item={item} icon="person" /><small>{item.group_nickname || item.display_name}</small>
@@ -735,6 +735,38 @@ function AdminList({topic, onToast}) {
</div>;
}
// 参考图 群聊设置.png 里"群成员"标题行是可点的("查看{N}群成员 >"),但 GroupSettings 一直没有
// 对应的落地页——群成员超过预览网格的9人就完全看不到其余成员。复用 MemberPicker 已经在用的
// 字母分组+搜索逻辑,去掉多选/提交,只读展示每个人的角色。
function GroupMembers({topic, onToast}) {
const [members, setMembers] = React.useState(() => (topic && readCachedData('group-members:' + topic)) || null);
const [query, setQuery] = React.useState('');
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic).then(data => {
const items = data.items || [];
writeCachedData('group-members:' + topic, items);
setMembers(items);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
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 roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
return <div className="group-page">
<Header title="群成员" back="group-settings" backTopic={topic} />
<Search value={query} onChange={setQuery} placeholder="搜索群成员" />
<main className="group-member-picker">
{members === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> : groups.map(([letter, list]) => <React.Fragment key={letter}>
<div className="group-section-title">{letter}</div>
{list.map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(topic, item.user_id, item)}>
<Avatar item={item} icon="person" /><span><strong>{item.group_nickname || item.display_name}</strong>{roleLabel(item.role) ? <small>{roleLabel(item.role)}</small> : null}</span>
</button>)}
</React.Fragment>)}
</main>
</div>;
}
// Shared alphabetically-grouped multi-select picker for admin-add / mute-add (same visual component,
// three purposes, per docs/images/添加管理员.png vs .../添加管理员-设置成功(1).png which turned out to
// actually be the mute-add variant of the identical layout).
@@ -986,7 +1018,9 @@ function GroupFiles({topic, tinode, onToast}) {
<button type="button" onClick={() => pick('video')}><Icon>play_circle_outline</Icon>上传视频</button>
</div> : null}
<Search value={queryInput} onChange={setQueryInput} placeholder="搜索文件名或者上传者" />
<p className="group-inline-hint">共{(files || []).length}个文件(已使用{gb(usedBytes)}GB/10GB)</p>
{/* "/10GB" 分母曾经是前端编的——群文件相关接口(含治理/设置)全仓库没有任何配额字段下发,
显示一个无依据的上限数字比不显示更容易误导人,改成只报告已用量。 */}
<p className="group-inline-hint">共{(files || []).length}个文件(已使用{gb(usedBytes)}GB)</p>
<main className="group-file-list">
{uploading ? <article className="uploading">
<span className="group-file-type" style={{backgroundColor: '#8a8f98'}}><Icon>upload_file</Icon></span>
@@ -1108,6 +1142,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
else if (view === 'group-nickname') content = <GroupNameEditor topic={topic} onToast={setToast} />;
else if (view === 'group-description') content = <GroupDescriptionEditor topic={topic} onToast={setToast} />;
else if (view === 'group-my-nickname') content = <MyNicknameEditor topic={topic} myUserId={myUserId} onToast={setToast} />;
else if (view === 'group-members') content = <GroupMembers topic={topic} onToast={setToast} />;
else if (view === 'group-qr') content = <GroupQr topic={topic} onToast={setToast} />;
else if (view === 'group-governance') content = <Governance topic={topic} 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} />;
+10 -3
View File
@@ -1514,9 +1514,16 @@ class MessagesView extends React.Component {
// Returns a promise resolving to `false` when the message was NOT sent (blocked/cancelled) so
// send-message.jsx can leave the composer text intact instead of optimistically clearing it --
// without this, a blocked message's text would just vanish with no failed-message trace to retry.
const urlMatch = originalText && originalText.match(/https?:\/\/\S+/);
if (urlMatch) {
return checkURL(urlMatch[0], 'message_compose').then(result => {
// Was missing the `g` flag -- .match() without it only ever returns the FIRST url in the message,
// so a second url (e.g. the phishing link in "看这个 https://safe.example.com 但是别信这个
// https://phish.example.com/login?token=abc") never got checked at all as long as the first one
// came back `allow`. Check every distinct url in the message and act on the riskiest verdict.
const urlMatches = originalText && originalText.match(/https?:\/\/\S+/g);
if (urlMatches) {
const RISK_RANK = {block: 2, warn: 1, allow: 0};
const urls = Array.from(new Set(urlMatches));
return Promise.all(urls.map(url => checkURL(url, 'message_compose'))).then(results => {
const result = results.reduce((worst, r) => (RISK_RANK[r.decision] || 0) > (RISK_RANK[worst.decision] || 0) ? r : worst);
if (result.decision === 'allow') {
return doSend();
}
+17 -2
View File
@@ -3,7 +3,7 @@ import QRCode from 'qrcodejs';
import HashNavigation from '../lib/navigation.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { BASE_APP_NAME } from '../config.js';
import { BASE_APP_NAME, LINK_CONTACT_US } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import {
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
@@ -315,6 +315,11 @@ function SettingsList({onToast, onLogout}) {
<MenuRow icon="child_care" title="青少年模式" value={youthEnabled == null ? '…' : (youthEnabled ? '已开启' : '未开启')}
route={youthEnabled ? 'youth-mode-on' : 'youth-mode-off'} />
<MenuRow icon="verified_user" title="实名认证" value={realnameLabel} route="realname" />
{/* security-support-view.jsx 的 FraudAwareness 组件自己的注释就写着这应该是个
"always-reachable safety-info page",但此前没有任何页面真的链接过去——只能靠手改
hash 才能打开。这是 personal-settings-view.jsx 之外的一个路由,和本文件其它跳转
用的是同一套 hash 导航,可以直接指过去。 */}
<MenuRow icon="shield" title="安全教育" route="fraud-risk-warning" />
</section>
<section><MenuRow icon="no_accounts" title="注销账号" route="delete-account-confirm" danger /></section>
<button type="button" className="personal-logout" onClick={logout}>退出当前登录</button>
@@ -411,7 +416,8 @@ function ChangePassword({onUpdatePassword, onToast}) {
setBusy(false);
});
};
return <div className="personal-page profile-editor-page"><Header title="修改密码" back="android-settings" action="完成" onAction={submit} /><main>
return <div className="personal-page profile-editor-page"><Header title="修改密码" back="android-settings" action="完成"
actionLabel={busy ? '处理中…' : '完成'} actionDisabled={busy} onAction={submit} /><main>
<label><span>原密码</span><input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} placeholder="请输入当前密码" /></label>
<label><span>新密码</span><input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} placeholder="至少6位" /></label>
<label><span>确认新密码</span><input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} placeholder="再次输入新密码" /></label>
@@ -457,6 +463,11 @@ function AppVersion({onToast}) {
<section className="personal-menu-section">
<MenuRow title="用户条款" onClick={() => onToast('用户条款')} />
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
{/* 参考图"版本更新页面.png"里这行是明确画出来的,之前漏掉了(跟"检测新版本"不一样,
那个是核对图片后判断web版没有应用商店可跳、主动决定不做,这里没有类似理由)。
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信,复用代码里已有的
LINK_CONTACT_US——如果这不是最终要用的联系方式,需要产品另外提供真实地址。 */}
<MenuRow title="联系我们" onClick={() => { window.location.href = LINK_CONTACT_US; }} />
</section>
</main>
</div>;
@@ -684,6 +695,10 @@ function YouthModeVerify({hashParams, onToast}) {
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy || remainingLockSeconds > 0} onClick={submit}>
{busy ? '验证中…' : remainingLockSeconds > 0 ? `请 ${remainingLockSeconds} 秒后重试` : '确定'}
</button>
{/* 一旦设过密码,这个页面就是唯一入口,之前完全没有出路——真忘了密码只能不断输错直到被
限流,限流也只是让你等,不会给别的办法。identity-check 走的短信验证码本来就支持在
password_set:true 时重新设置密码(实测确认过),只是没有从这里链接过去。 */}
<button type="button" className="auth-text-button" onClick={() => navigate('identity-check')}>忘记独立密码?</button>
</main>
</div>;
}
+34 -6
View File
@@ -124,11 +124,11 @@ const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','c
'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-my-nickname','group-members','group-role-message','group-qr','group-governance','group-admins','group-admin-add',
'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add',
'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload',
'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice',
'favorites-links','favorites-select','favorite-share','file-download','file-downloading',
'favorites-links','favorites-image','favorites-video','favorites-select','favorite-share','file-download','file-downloading',
'file-downloaded','history-search','history-files','history-links','history-images','history-videos',
'call-history','call-incoming','audio-call-ringing','audio-call-active','audio-call-muted',
'audio-call-speaker','video-call-ringing','video-call-camera-off','video-call-active',
@@ -192,6 +192,19 @@ function loginErrorMessage(err) {
if (err && (err.code === 401 || err.code === 403)) return '账号或密码错误';
return (err && err.message) || '登录失败,请重试';
}
// navigateToCredentialsView 拒绝导航时,ctrl.params.reason 用来给用户一句能看懂的话——目前只有
// INVITE_CODE_INVALID/INVITE_CODE_REQUIRED 两个真实用真实请求验证过,REVOKED/EXPIRED 是同一命名
// 规律下合理的兄弟值,未逐一实测,先按同一套文案兜底,不影响已确认的两个分支。
const INVITE_CODE_REASON_TEXT_CN = {
INVITE_CODE_REQUIRED: '请填写邀请码',
INVITE_CODE_INVALID: '邀请码错误,请重新输入',
INVITE_CODE_REVOKED: '邀请码已被撤销,请联系管理员获取新的邀请码',
INVITE_CODE_EXPIRED: '邀请码已过期,请联系管理员获取新的邀请码'
};
function invalidCredentialsReasonMessage(params) {
const reason = params && params.reason;
return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试';
}
// See armCallRingTimeout below.
const CALL_RING_TIMEOUT_MS = 60000;
@@ -985,7 +998,9 @@ class TinodeWeb extends React.Component {
if (cred) {
this.handleError(this.props.intl.formatMessage(messages.code_doesnot_match), 'warn');
}
TinodeWeb.navigateToCredentialsView(ctrl.params);
if (!TinodeWeb.navigateToCredentialsView(ctrl.params) && !cred) {
this.handleError(invalidCredentialsReasonMessage(ctrl.params), 'err');
}
} else {
this.handleLoginSuccessful();
}
@@ -1015,13 +1030,24 @@ class TinodeWeb extends React.Component {
});
}
// ctrl.code>=300 / ctrl.text=='validate credentials' is standard Tinode for "account created,
// now confirm a real credential (email code etc.)" -- ctrl.params.cred is guaranteed non-empty in
// that case. This backend reuses the exact same code/text pair for its own invite-code errors
// (INVITE_CODE_INVALID/INVITE_CODE_REQUIRED, confirmed live), which carry `reason`/`what` instead
// of `cred` -- calling this without checking left `params.cred[0]` throwing a raw TypeError that
// surfaced to the user as unreadable English. Returns whether it actually navigated, so both call
// sites can show a real error message instead when it didn't.
static navigateToCredentialsView(params) {
if (!Array.isArray(params.cred) || !params.cred.length) {
return false;
}
const parsed = HashNavigation.parseUrlHash(window.location.hash);
parsed.path[0] = 'cred';
parsed.params['method'] = params.cred[0];
parsed.params['token'] = params.token;
parsed.params['code'] = params.code;
HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params));
return true;
}
handleLoginSuccessful() {
@@ -1458,7 +1484,9 @@ class TinodeWeb extends React.Component {
{public: public_, tags: tags_, cred: Tinode.credential(cred_), attachments: attachments});
}).then(ctrl => {
if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
TinodeWeb.navigateToCredentialsView(ctrl.params);
if (!TinodeWeb.navigateToCredentialsView(ctrl.params)) {
this.handleError(invalidCredentialsReasonMessage(ctrl.params), 'err');
}
} else {
this.handleLoginSuccessful(this);
}
@@ -2611,12 +2639,12 @@ class TinodeWeb extends React.Component {
const chapterSixActive = ['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-nickname', 'group-description', 'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance',
'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial',
'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select',
'group-owner-confirm', 'group-files', 'group-file-upload'].includes(activeProductView);
const chapterSevenActive = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text',
'favorites-voice', 'favorites-links', 'favorites-select', 'favorite-share', 'file-download',
'favorites-voice', 'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download',
'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links',
'history-images', 'history-videos'].includes(activeProductView);
const callExperienceActive = ['call-history', 'call-incoming', 'audio-call-ringing',
+14 -4
View File
@@ -230,7 +230,7 @@ function DeptNode({dept, depth, myUserId}) {
setLoading(true);
setLoadError(false);
Promise.all([
getOrganizationDepartments(dept.department_id).then(d => d.departments || d.items || []),
dept.child_count ? getOrganizationDepartments(dept.department_id).then(d => d.departments || d.items || []) : Promise.resolve([]),
dept.member_count ? getOrganizationDepartmentMembers(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([])
]).then(([depts, mems]) => { setChildren(depts); setMembers(mems); })
// Without this, a rejected Promise.all left children/members at null forever -- rendered as
@@ -458,10 +458,14 @@ function MeetingCreate({myUserId, tinode, onToast}) {
}
const startAt = new Date(`${date}T${startTime}:00`).toISOString();
const endAt = new Date(`${date}T${endTime}:00`).toISOString();
// Backend 400s with {details:{field:"end_at"}} for this (real request confirmed), but nothing
// upstream of onToast reads `details` -- catching it locally avoids a round-trip for the single
// most likely input mistake (single shared date, two independent time inputs).
if (endAt <= startAt) { onToast('结束时间需晚于开始时间'); return; }
setSubmitting(true);
createMeeting({title: title.trim(), start_at: startAt, end_at: endAt, location: location.trim(), attendee_user_ids: selected})
.then(_ => { onToast('会议已发起'); navigate('meeting-attendee'); })
.catch(err => onToast(err.message || '发起会议失败'))
.catch(err => onToast((err.details && err.details.field === 'end_at') ? '结束时间需晚于开始时间' : (err.message || '发起会议失败')))
.finally(() => setSubmitting(false));
};
@@ -813,6 +817,11 @@ function MonthlyReportEditor({onToast}) {
const setVersionBoth = v => { versionRef.current = v; setVersion(v); };
const [summary, setSummary] = React.useState(cached ? cached.summary : '');
const [plan, setPlan] = React.useState(cached ? cached.plan : '');
// monthlyReportDetailPayload's real validation.max_length is 10000, not the 1000 this page used to
// hardcode -- confirmed live (a 2000-char draft save was accepted while this UI would have blocked
// it at 1000). Keep 1000 as the pre-load fallback so the textarea has *some* limit before the first
// response comes back, not because it's the real cap.
const [maxLen, setMaxLen] = React.useState(1000);
// A cached seed can go stale the instant something outside this component's lifetime mutates the
// report's version -- concretely, MonthlyReportDetail's "重新编辑" (reopenMonthlyReport) does that,
// then navigates straight here. Live-reproduced: reopen -> land here with the cache still holding
@@ -849,6 +858,7 @@ function MonthlyReportEditor({onToast}) {
const nextSummary = r.work_summary || ''; const nextPlan = r.next_plan || '';
setReportId(r.report_id); setVersionBoth(r.version);
setSummary(nextSummary); setPlan(nextPlan);
if (r.validation && r.validation.max_length) setMaxLen(r.validation.max_length);
writeCachedData(cacheKey, {reportId: r.report_id, version: r.version, summary: nextSummary, plan: nextPlan});
setHydrated(true);
}).catch(err => onToast(err.message || '创建月报失败'));
@@ -879,9 +889,9 @@ function MonthlyReportEditor({onToast}) {
<main>
<section className="daily-report-block-header"><strong>月报汇总</strong></section>
<label className="report-field"><span><b>*</b>本月工作总结</span>
<textarea value={summary} maxLength="1000" placeholder="请输入" onChange={e => setSummary(e.target.value)} /><small>{summary.length}/1000</small></label>
<textarea value={summary} maxLength={maxLen} placeholder="请输入" onChange={e => setSummary(e.target.value)} /><small>{summary.length}/{maxLen}</small></label>
<label className="report-field"><span><b>*</b>下月工作计划</span>
<textarea value={plan} maxLength="1000" placeholder="请输入" onChange={e => setPlan(e.target.value)} /><small>{plan.length}/1000</small></label>
<textarea value={plan} maxLength={maxLen} placeholder="请输入" onChange={e => setPlan(e.target.value)} /><small>{plan.length}/{maxLen}</small></label>
<p className="report-draft-status">{saveError ? '草稿保存失败,请检查网络后重新编辑' : saved ? '草稿已自动保存' : '正在保存草稿…'}</p>
</main>
<footer className="workplace-submit-bar"><button type="button" disabled={submitting || !reportId} onClick={submit}>{submitting ? '提交中…' : '提交'}</button></footer>
+20 -1
View File
@@ -6,6 +6,24 @@ import { createFileDownloader } from '../lib/im-api.js';
import { bytesToHumanSize } from '../lib/strformat.js';
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
// Same broad-category icon+color convention already established for file cards elsewhere
// (content-library-view.jsx / group-management-view.jsx's FILE_TYPE_META) -- this was the one place
// that never adopted it and instead used the same generic gray icon for every attachment regardless
// of type. This client-side mime sniff exists only because chat attachments (unlike a group file
// or a favorite) don't carry a server-computed file_type field to look up directly.
const ATTACHMENT_TYPE_META = {image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'], document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']};
function attachmentTypeFromMime(mime) {
const m = (mime || '').toLowerCase();
if (m.startsWith('image/')) return 'image';
if (m.startsWith('video/')) return 'video';
if (m.startsWith('audio/')) return 'audio';
if (m === 'application/pdf' || m === 'text/plain' || m.includes('msword') || m.includes('wordprocessingml') ||
m.includes('spreadsheetml') || m.includes('presentationml') || m === 'application/vnd.ms-excel' || m === 'application/vnd.ms-powerpoint') {
return 'document';
}
return 'file';
}
export default class Attachment extends React.Component {
constructor(props) {
super(props);
@@ -78,9 +96,10 @@ export default class Attachment extends React.Component {
}
const downloadWidget = <><i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
defaultMessage="save" description="Call to save an attachment" /></>;
const [icon, color] = ATTACHMENT_TYPE_META[attachmentTypeFromMime(this.props.mimetype)];
return (
<div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div>
<div><i className="material-icons big" style={{color}}>{icon}</i></div>
<div className="flex-column">
<div>{filename} {size}</div>
{this.props.uploading || this.state.downloader ?
+27 -3
View File
@@ -226,6 +226,16 @@ class CallPanel extends React.PureComponent {
onInfo(info) {
if (info.what != 'call') {
// This call panel takes over topic.onInfo wholesale for the lifetime of the call (including
// while minimized -- the product lets messaging continue during a call), but was only ever
// handling 'call' events itself and dropping everything else on the floor instead of forwarding
// to the handler it displaced (messages-view.jsx's handleInfoReceipt, which drives the "typing"
// indicator and read/received checkmarks). Real-time {info} isn't replayed later, so every
// typing/read/recv update for this topic was silently lost for as long as the panel stayed
// mounted.
if (this.previousOnInfo) {
this.previousOnInfo(info);
}
return;
}
switch (info.event) {
@@ -839,10 +849,18 @@ class CallPanel extends React.PureComponent {
if (this.props.minimized && this.state.audioOnly) {
// Voice calls minimize to a small pill (icon + timer), not the video-shaped box.
return (
// The restore button used to live inside .call-pill.draggable -- dragStart only checks
// event.target.closest('.draggable') before calling setPointerCapture() on the whole
// container, and once a pointer is captured, Pointer Events retargets subsequent events
// (including the click) to the capturing element instead of the button underneath it. The
// video-minimized layout below already keeps its own restore button as a sibling of the
// draggable region for exactly this reason; this pill just hadn't followed the same rule.
<div id="video-container" className="minimized audio-minimized" ref={this.containerRef}>
<div className="call-pill draggable">
<i className="material-icons">call</i>
<span className="call-pill-duration">{formatElapsedTime(this.state.elapsedSeconds)}</span>
<div className="call-pill-row">
<div className="call-pill draggable">
<i className="material-icons">call</i>
<span className="call-pill-duration">{formatElapsedTime(this.state.elapsedSeconds)}</span>
</div>
<button className="full-screen" onClick={this.props.onToggleMinimize}>
<i className="material-icons">{fullScreen}</i>
</button>
@@ -880,6 +898,12 @@ class CallPanel extends React.PureComponent {
title={this.props.title} />
</div>
<div className="caller-name">{peerTitle}</div>
{/* This same !remoteActive branch also renders once the call is live but the peer's
own camera happens to be off -- only the pre-connect phase should claim we're
still waiting on them to answer. Reference screenshots (视频通话—等待对方接受
邀请中.png etc.) and this project's own call-experience-view.jsx demo both show
this line; only the real call-panel.jsx was missing it. */}
{!connected ? <p className="caller-status">正在等待对方接受邀请…</p> : null}
</div>
</>
}
+56 -2
View File
@@ -15,7 +15,10 @@ import { forwardFavorites, getFavorites, getFileUrl, getFriends, translateReason
// 4.5-4.6 表情面板:参考图确认了"只有2个tab"(😊表情 / ♡)和"发送按钮浅蓝禁用、深蓝启用"这两点,
// 但没有任何一张图能看清第二个tab具体展示什么内容——判断为"常用"(这是这类两tab设计里最常见的
// 语义,也是本项目其它地方唯一有本地存储依据能撑起来的一种:不存在一个真实的"收藏表情"后端概念)。
// 表情本身的具体清单同样没有图片依据,选了一组常见、覆盖面广的 Unicode 表情,不追求详尽分类。
// 表情本身的具体清单——纠正一处之前的误判:参考图`表情包展开(发送按钮默认状态).png`/`表情包
// 发送页面.png`其实清楚画出了一套具体的经典"黄脸"贴图(微信/QQ经典表情那种视觉风格),不是没有
// 图片依据。只是这个项目没有对应的贴图素材资源,做不到跟截图完全一致的视觉,退而求其次选了一组
// 常见、覆盖面广的系统 Unicode 表情——这是素材缺口下的折中方案,不是"没有依据、随便选的"。
const EMOJI_LIST = ['😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', '😋', '😎', '😍', '😘',
'🥰', '😗', '😙', '😚', '🙂', '🤗', '🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
'🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜', '😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑',
@@ -143,6 +146,7 @@ class SendMessage extends React.PureComponent {
this.handleHoldVisibilityChange = this.handleHoldVisibilityChange.bind(this);
this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this);
this.insertEmoji = this.insertEmoji.bind(this);
this.deleteLastChar = this.deleteLastChar.bind(this);
this.toggleAttachPanel = this.toggleAttachPanel.bind(this);
this.openCardPicker = this.openCardPicker.bind(this);
this.openFavPicker = this.openFavPicker.bind(this);
@@ -460,6 +464,37 @@ class SendMessage extends React.PureComponent {
});
}
// 参考图的表情面板自带一个退格键,让"只点表情不碰键盘"这条路径也能删掉打错的字符——用
// Array.from 而不是 slice(0,-1) 切最后一个 Unicode 码点,避免把一个占两个UTF-16码元的emoji
// (比如复合肤色/家庭类emoji)齐腰斩断,删出半个乱码字符。
deleteLastChar() {
const ta = this.messageEditArea;
const start = ta ? ta.selectionStart : this.state.message.length;
const end = ta ? ta.selectionEnd : this.state.message.length;
let newMessage, caretPos;
if (start !== end) {
newMessage = this.state.message.slice(0, start) + this.state.message.slice(end);
caretPos = start;
} else if (start > 0) {
const before = Array.from(this.state.message.slice(0, start));
before.pop();
newMessage = before.join('') + this.state.message.slice(start);
caretPos = before.join('').length;
} else {
return;
}
this.setState({message: newMessage});
if (this.props.onMessageChange) {
this.props.onMessageChange(newMessage);
}
requestAnimationFrame(() => {
if (this.messageEditArea) {
this.messageEditArea.focus();
this.messageEditArea.setSelectionRange(caretPos, caretPos);
}
});
}
toggleAttachPanel(e) {
e.preventDefault();
this.setState(s => ({attachOpen: !s.attachOpen, emojiOpen: false}));
@@ -482,7 +517,14 @@ class SendMessage extends React.PureComponent {
const build = avatarUrl => {
const card = new TheCard(item.display_name || item.user_id, avatarUrl);
TheCard.setTinodeID(card, item.user_id);
this.props.onSendMessage(Drafty.appendTheCard(null, card));
// Every other attachment sender (image/video/file/voice) threads a typeHint through to
// messages-view.jsx's sendMessage(msg, uploadCompletionPromise, uploader, typeHint) so the
// backend's imMessageType() classifier -- which only ever looks at head['x-im-type'] or a flat
// content.file_id/url, never at real Drafty entities -- doesn't fall through to its "text"
// default. This call was only ever passing the first argument, so every card message has been
// silently misclassified as plain text (confirmed live: search/history summarize it as raw
// Drafty JSON instead of a name).
this.props.onSendMessage(Drafty.appendTheCard(null, card), undefined, undefined, 'card');
};
if (item.avatar_file_id) {
getFileUrl(item.avatar_file_id).then(build).catch(() => build(undefined));
@@ -689,6 +731,18 @@ class SendMessage extends React.PureComponent {
<a href="#" key={emoji + i} onClick={e => {e.preventDefault(); this.insertEmoji(emoji);}}>{emoji}</a>
))}
</div>
{/* 参考图表情网格下方那一整行退格/发送专属控件——原来完全没做,"只点表情不碰键盘"
这条路径下用户还得把视线移出面板去点外面的发送图标。复用已有的 sendIcon/handleSend,
不重新发明发送逻辑。 */}
<div id="emoji-panel-footer">
<a href="#" onClick={e => {e.preventDefault(); this.deleteLastChar();}} title="删除">
<i className="material-icons">backspace</i>
</a>
<a href="#" className={'emoji-panel-send' + (this.state.message ? ' active' : '')}
onClick={e => {e.preventDefault(); this.handleSend(e);}} title={formatMessage(messages.icon_title_send)}>
<i className="material-icons">{sendIcon}</i>
</a>
</div>
</div> : null}
{this.state.attachOpen ?
<div id="attach-panel">
+159 -30
View File
@@ -4420,8 +4420,9 @@ const updateDailyDraft = (reportId, content, version, attachments = []) => imPut
const submitDailyReport = (reportId, version) => imPost('/reports/daily/' + encodeURIComponent(reportId) + '/submit', {
version
});
const getDailyStatistics = date => imGet('/reports/daily/statistics' + qs({
date
const getDailyStatistics = (date, departmentId = '') => imGet('/reports/daily/statistics' + qs({
date,
department_id: departmentId
}));
const sendDailyReminders = (date, userIds = []) => imPost('/reports/daily/reminders', {
date,
@@ -4456,8 +4457,9 @@ const submitMonthlyReport = (reportId, version) => imPost('/reports/monthly/' +
const reopenMonthlyReport = (reportId, version) => imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {
version
});
const getMonthlyStatistics = month => imGet('/reports/monthly/statistics' + qs({
month
const getMonthlyStatistics = (month, departmentId = '') => imGet('/reports/monthly/statistics' + qs({
month,
department_id: departmentId
}));
const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config');
const submitExecutiveMailboxMessage = body => imPost('/executive-mailbox/submissions', body);
@@ -5632,6 +5634,7 @@ function Header({
}
function Field({
icon,
onIconClick,
action,
error,
className = '',
@@ -5641,7 +5644,14 @@ function Field({
className: 'auth-field-wrap ' + className,
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("div", {
className: 'auth-field' + (error ? ' invalid' : ''),
children: [icon ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(Icon, {
children: [icon ? onIconClick ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("button", {
type: "button",
className: "auth-field-icon-button",
onClick: onIconClick,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(Icon, {
children: icon
}, void 0, false)
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)(Icon, {
className: "auth-field-icon",
children: icon
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("input", {
@@ -5974,7 +5984,8 @@ function EnterpriseRegister() {
placeholder: "请输入18位统一社会信用代码",
maxLength: "18",
icon: "qr_code_scanner",
error: errors.creditCode
error: errors.creditCode,
onIconClick: () => setToast('扫码识别暂不可用,请手动输入')
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__.jsxDEV)("p", {
className: "auth-help",
children: "请填写营业执照上的统一社会信用代码"
@@ -8869,7 +8880,7 @@ const navigate = (view, params) => {
});
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(hash);
};
const CONTENT_LIBRARY_VIEWS = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos'];
const CONTENT_LIBRARY_VIEWS = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos'];
function Icon({
children
}) {
@@ -9181,7 +9192,9 @@ const TYPE_ROUTE = {
file: 'favorites-files',
text: 'favorites-text',
voice: 'favorites-voice',
link: 'favorites-links'
link: 'favorites-links',
image: 'favorites-image',
video: 'favorites-video'
};
const TYPE_LABELS = [['all', '全部'], ['image', '图片'], ['video', '视频'], ['file', '文件'], ['voice', '语音'], ['text', '文本'], ['link', '链接']];
function Favorites({
@@ -9189,7 +9202,7 @@ function Favorites({
tinode,
onToast
}) {
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : 'all';
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : view === 'favorites-image' ? 'image' : view === 'favorites-video' ? 'video' : 'all';
const selecting = view === 'favorites-select';
const [type, setType] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(selecting ? 'file' : initialType);
const [queryInput, setQueryInput] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
@@ -9258,7 +9271,8 @@ function Favorites({
const openItem = item => {
if (item.type === 'file') {
navigate('file-download', {
favoriteId: item.favorite_id
favoriteId: item.favorite_id,
fromType: type
});
return;
}
@@ -9269,6 +9283,10 @@ function Favorites({
}).catch(() => onToast('链接安全校验失败'));
return;
}
if (item.type === 'image' || item.type === 'video') {
if (item._thumbUrl) window.open(item._thumbUrl, '_blank', 'noopener,noreferrer');else onToast('正在加载,请稍后重试');
return;
}
onToast('暂不支持预览此类收藏');
};
const list = items || [];
@@ -9372,8 +9390,11 @@ function FavoriteShare({
setSending(true);
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_5__.forwardFavorites)(ids, selected, comment.trim()).then(result => {
const items = result && result.items || [];
const failedCount = items.filter(r => r.status !== 'sent').length;
onToast(failedCount ? `${selected.length - failedCount}/${selected.length} 个会话分享成功` : `已分享给 ${selected.length} 个会话`);
const successTargets = selected.filter(target => {
const forTarget = items.filter(r => r.target_topic === target);
return forTarget.length > 0 && forTarget.every(r => r.status === 'sent');
}).length;
onToast(successTargets === selected.length ? `已分享给 ${selected.length} 个会话` : `${successTargets}/${selected.length} 个会话分享成功`);
window.setTimeout(() => navigate('favorites-files'), 700);
}).catch(err => onToast(err.message || '分享失败')).finally(() => setSending(false));
};
@@ -9475,9 +9496,10 @@ function DownloadPage({
const {
favoriteId,
topic,
seq
seq,
fromType
} = hashParams || {};
const backView = favoriteId ? 'favorites-files' : 'history-files';
const backView = favoriteId ? TYPE_ROUTE[fromType] || 'favorites-files' : 'history-files';
const backParams = favoriteId ? undefined : {
topic
};
@@ -9825,8 +9847,10 @@ function History({
}, void 0, false) : view === 'history-links' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("section", {
className: "library-record-links",
children: list.map(item => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)((react__WEBPACK_IMPORTED_MODULE_0___default().Fragment), {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
type: "button",
className: "library-record-header",
onClick: () => jump(item.seq),
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("strong", {
children: nameOf(item.from)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("small", {
@@ -10645,7 +10669,7 @@ const navigateToProfile = (topic, userId, member) => {
}).catch(() => {});
}
};
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'];
const GROUP_MANAGEMENT_VIEWS = ['group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description', 'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance', 'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files', 'group-file-upload'];
function alphaInitial(name) {
const ch = (name || '').trim()[0] || '#';
if (/[A-Za-z]/.test(ch)) {
@@ -11453,13 +11477,17 @@ function GroupSettings({
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("section", {
className: "group-member-section",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
className: "group-member-section-title",
onClick: () => navigate('group-members', topic),
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("strong", {
children: "群成员"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", {
children: ["共 ", group.member_count, " 位成员"]
}, void 0, true)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
children: "chevron_right"
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "group-member-grid",
children: [members.slice(0, 9).map(item => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
@@ -12057,6 +12085,64 @@ function AdminList({
}, void 0, false)]
}, void 0, true);
}
function GroupMembers({
topic,
onToast
}) {
const [members, setMembers] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(() => topic && (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.readCachedData)('group-members:' + topic) || null);
const [query, setQuery] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
const [loadError, setLoadError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
if (!topic) return;
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMembers)(topic).then(data => {
const items = data.items || [];
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('group-members:' + topic, items);
setMembers(items);
}).catch(() => {
onToast('加载失败');
setLoadError(true);
});
}, [topic]);
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 roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "group-page",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Header, {
title: "群成员",
back: "group-settings",
backTopic: topic
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Search, {
value: query,
onChange: setQuery,
placeholder: "搜索群成员"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("main", {
className: "group-member-picker",
children: members === null ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("p", {
className: "group-inline-empty",
children: loadError ? '成员列表加载失败' : '加载中…'
}, void 0, false) : groups.map(([letter, list]) => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)((react__WEBPACK_IMPORTED_MODULE_0___default().Fragment), {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "group-section-title",
children: letter
}, void 0, false), list.map(item => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
onClick: () => navigateToProfile(topic, item.user_id, item),
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Avatar, {
item: item,
icon: "person"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("strong", {
children: item.group_nickname || item.display_name
}, void 0, false), roleLabel(item.role) ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("small", {
children: roleLabel(item.role)
}, void 0, false) : null]
}, void 0, true)]
}, item.user_id, true))]
}, letter, true))
}, void 0, false)]
}, void 0, true);
}
function MemberPicker({
type,
topic,
@@ -12503,7 +12589,7 @@ function GroupFiles({
placeholder: "搜索文件名或者上传者"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("p", {
className: "group-inline-hint",
children: ["共", (files || []).length, "个文件(已使用", gb(usedBytes), "GB/10GB)"]
children: ["共", (files || []).length, "个文件(已使用", gb(usedBytes), "GB)"]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("main", {
className: "group-file-list",
children: [uploading ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("article", {
@@ -12756,6 +12842,9 @@ function GroupManagementView({
topic: topic,
myUserId: myUserId,
onToast: setToast
}, void 0, false);else if (view === 'group-members') content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(GroupMembers, {
topic: topic,
onToast: setToast
}, void 0, false);else if (view === 'group-qr') content = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(GroupQr, {
topic: topic,
onToast: setToast
@@ -15420,6 +15509,10 @@ function SettingsList({
title: "实名认证",
value: realnameLabel,
route: "realname"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(MenuRow, {
icon: "shield",
title: "安全教育",
route: "fraud-risk-warning"
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("section", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(MenuRow, {
@@ -15582,6 +15675,8 @@ function ChangePassword({
title: "修改密码",
back: "android-settings",
action: "完成",
actionLabel: busy ? '处理中…' : '完成',
actionDisabled: busy,
onAction: submit
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("main", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("label", {
@@ -15694,6 +15789,11 @@ function AppVersion({
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(MenuRow, {
title: "隐私协议",
onClick: () => onToast('隐私协议')
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)(MenuRow, {
title: "联系我们",
onClick: () => {
window.location.href = _config_js__WEBPACK_IMPORTED_MODULE_4__.LINK_CONTACT_US;
}
}, void 0, false)]
}, void 0, true)]
}, void 0, true)]
@@ -16048,6 +16148,11 @@ function YouthModeVerify({
disabled: password.length !== 4 || busy || remainingLockSeconds > 0,
onClick: submit,
children: busy ? '验证中…' : remainingLockSeconds > 0 ? `请 ${remainingLockSeconds} 秒后重试` : '确定'
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("button", {
type: "button",
className: "auth-text-button",
onClick: () => navigate('identity-check'),
children: "忘记独立密码?"
}, void 0, false)]
}, void 0, true)]
}, void 0, true);
@@ -17610,7 +17715,7 @@ const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
description: 'Placeholder for the country selector'
}
});
const SIDE_PANEL_VIEWS = ['acc_general', 'archive', 'blocked', 'contacts', 'cred', 'crop', 'edit', 'general', 'newtpk', 'notif', 'register', 'reset', 'security', 'settings', 'support', 'wallpapers', 'tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password', 'messages-home', 'messages-offline', 'messages-menu', 'message-actions', 'system-messages', 'chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request', 'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard', 'chat-attachments', 'chat-mixed', 'chat-contact-card', 'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message', 'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording', 'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message', 'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle', 'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-qr', 'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm', 'clear-chat-confirm', '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', 'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos', 'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active', 'audio-call-muted', 'audio-call-speaker', 'video-call-ringing', 'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off', 'call-minimized', 'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice', 'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings', 'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm', 'app-version', 'privacy-settings', 'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify', 'notification-permission', 'permission-settings-reminder', 'camera-permission-info', 'microphone-permission-info', 'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons', 'abuse-report-details', 'user-feedback', ''];
const SIDE_PANEL_VIEWS = ['acc_general', 'archive', 'blocked', 'contacts', 'cred', 'crop', 'edit', 'general', 'newtpk', 'notif', 'register', 'reset', 'security', 'settings', 'support', 'wallpapers', 'tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password', 'messages-home', 'messages-offline', 'messages-menu', 'message-actions', 'system-messages', 'chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request', 'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard', 'chat-attachments', 'chat-mixed', 'chat-contact-card', 'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message', 'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording', 'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message', 'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle', 'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-qr', 'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm', 'clear-chat-confirm', 'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description', 'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance', 'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files', 'group-file-upload', 'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos', 'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active', 'audio-call-muted', 'audio-call-speaker', 'video-call-ringing', 'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off', 'call-minimized', 'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice', 'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings', 'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm', 'app-version', 'privacy-settings', 'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify', 'notification-permission', 'permission-settings-reminder', 'camera-permission-info', 'microphone-permission-info', 'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons', 'abuse-report-details', 'user-feedback', ''];
const AUTH_FLOW_VIEWS = ['tenant', 'login', 'register', 'reset', 'cred', 'enterprise-register', 'enterprise-feedback'];
const CTRL_ERROR_TEXT_CN = {
'authentication failed': '账号或密码错误',
@@ -17631,6 +17736,16 @@ function loginErrorMessage(err) {
if (err && (err.code === 401 || err.code === 403)) return '账号或密码错误';
return err && err.message || '登录失败,请重试';
}
const INVITE_CODE_REASON_TEXT_CN = {
INVITE_CODE_REQUIRED: '请填写邀请码',
INVITE_CODE_INVALID: '邀请码错误,请重新输入',
INVITE_CODE_REVOKED: '邀请码已被撤销,请联系管理员获取新的邀请码',
INVITE_CODE_EXPIRED: '邀请码已过期,请联系管理员获取新的邀请码'
};
function invalidCredentialsReasonMessage(params) {
const reason = params && params.reason;
return reason && INVITE_CODE_REASON_TEXT_CN[reason] || '注册信息校验未通过,请检查后重试';
}
const CALL_RING_TIMEOUT_MS = 60000;
class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component) {
constructor(props) {
@@ -18302,7 +18417,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
if (cred) {
this.handleError(this.props.intl.formatMessage(messages.code_doesnot_match), 'warn');
}
TinodeWeb.navigateToCredentialsView(ctrl.params);
if (!TinodeWeb.navigateToCredentialsView(ctrl.params) && !cred) {
this.handleError(invalidCredentialsReasonMessage(ctrl.params), 'err');
}
} else {
this.handleLoginSuccessful();
}
@@ -18326,12 +18443,16 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
});
}
static navigateToCredentialsView(params) {
if (!Array.isArray(params.cred) || !params.cred.length) {
return false;
}
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
parsed.path[0] = 'cred';
parsed.params['method'] = params.cred[0];
parsed.params['token'] = params.token;
parsed.params['code'] = params.code;
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].composeUrlHash(parsed.path, parsed.params));
return true;
}
handleLoginSuccessful() {
this.handleError();
@@ -18663,7 +18784,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
});
}).then(ctrl => {
if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
TinodeWeb.navigateToCredentialsView(ctrl.params);
if (!TinodeWeb.navigateToCredentialsView(ctrl.params)) {
this.handleError(invalidCredentialsReasonMessage(ctrl.params), 'err');
}
} else {
this.handleLoginSuccessful(this);
}
@@ -19691,8 +19814,8 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
const chapterThreeActive = ['chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request', 'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard', 'chat-attachments', 'chat-mixed', 'chat-contact-card'].includes(activeProductView);
const chapterFourActive = ['compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message', 'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording', 'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message', 'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle'].includes(activeProductView);
const chapterFiveActive = ['address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-qr', 'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm', 'clear-chat-confirm'].includes(activeProductView);
const chapterSixActive = ['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'].includes(activeProductView);
const chapterSevenActive = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos'].includes(activeProductView);
const chapterSixActive = ['group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description', 'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance', 'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files', 'group-file-upload'].includes(activeProductView);
const chapterSevenActive = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos'].includes(activeProductView);
const callExperienceActive = ['call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active', 'audio-call-muted', 'audio-call-speaker', 'video-call-ringing', 'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off', 'call-minimized'].includes(activeProductView);
const workplaceCollaborationActive = ['workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView);
const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings', 'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm', 'app-version', 'privacy-settings', 'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify', 'notification-permission', 'permission-settings-reminder', 'camera-permission-info', 'microphone-permission-info'].includes(activeProductView);
@@ -20575,7 +20698,7 @@ function DeptNode({
if (children != null) return;
setLoading(true);
setLoadError(false);
Promise.all([(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartments)(dept.department_id).then(d => d.departments || d.items || []), dept.member_count ? (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartmentMembers)(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([])]).then(([depts, mems]) => {
Promise.all([dept.child_count ? (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartments)(dept.department_id).then(d => d.departments || d.items || []) : Promise.resolve([]), dept.member_count ? (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.getOrganizationDepartmentMembers)(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([])]).then(([depts, mems]) => {
setChildren(depts);
setMembers(mems);
}).catch(() => setLoadError(true)).finally(() => setLoading(false));
@@ -20993,6 +21116,10 @@ function MeetingCreate({
}
const startAt = new Date(`${date}T${startTime}:00`).toISOString();
const endAt = new Date(`${date}T${endTime}:00`).toISOString();
if (endAt <= startAt) {
onToast('结束时间需晚于开始时间');
return;
}
setSubmitting(true);
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_3__.createMeeting)({
title: title.trim(),
@@ -21003,7 +21130,7 @@ function MeetingCreate({
}).then(_ => {
onToast('会议已发起');
navigate('meeting-attendee');
}).catch(err => onToast(err.message || '发起会议失败')).finally(() => setSubmitting(false));
}).catch(err => onToast(err.details && err.details.field === 'end_at' ? '结束时间需晚于开始时间' : err.message || '发起会议失败')).finally(() => setSubmitting(false));
};
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
className: "workplace-page report-editor-page",
@@ -21644,6 +21771,7 @@ function MonthlyReportEditor({
};
const [summary, setSummary] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(cached ? cached.summary : '');
const [plan, setPlan] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(cached ? cached.plan : '');
const [maxLen, setMaxLen] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(1000);
const [hydrated, setHydrated] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(!cached);
const [saved, setSaved] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
const [saveError, setSaveError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
@@ -21681,6 +21809,7 @@ function MonthlyReportEditor({
setVersionBoth(r.version);
setSummary(nextSummary);
setPlan(nextPlan);
if (r.validation && r.validation.max_length) setMaxLen(r.validation.max_length);
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_4__.writeCachedData)(cacheKey, {
reportId: r.report_id,
version: r.version,
@@ -21735,11 +21864,11 @@ function MonthlyReportEditor({
}, void 0, false), "本月工作总结"]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("textarea", {
value: summary,
maxLength: "1000",
maxLength: maxLen,
placeholder: "请输入",
onChange: e => setSummary(e.target.value)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("small", {
children: [summary.length, "/1000"]
children: [summary.length, "/", maxLen]
}, void 0, true)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
className: "report-field",
@@ -21749,11 +21878,11 @@ function MonthlyReportEditor({
}, void 0, false), "下月工作计划"]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("textarea", {
value: plan,
maxLength: "1000",
maxLength: maxLen,
placeholder: "请输入",
onChange: e => setPlan(e.target.value)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("small", {
children: [plan.length, "/1000"]
children: [plan.length, "/", maxLen]
}, void 0, true)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("p", {
className: "report-draft-status",
+1 -1
View File
File diff suppressed because one or more lines are too long
+91 -7
View File
@@ -1337,9 +1337,16 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}
return result;
};
const urlMatch = originalText && originalText.match(/https?:\/\/\S+/);
if (urlMatch) {
return (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.checkURL)(urlMatch[0], 'message_compose').then(result => {
const urlMatches = originalText && originalText.match(/https?:\/\/\S+/g);
if (urlMatches) {
const RISK_RANK = {
block: 2,
warn: 1,
allow: 0
};
const urls = Array.from(new Set(urlMatches));
return Promise.all(urls.map(url => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.checkURL)(url, 'message_compose'))).then(results => {
const result = results.reduce((worst, r) => (RISK_RANK[r.decision] || 0) > (RISK_RANK[worst.decision] || 0) ? r : worst);
if (result.decision === 'allow') {
return doSend();
}
@@ -2398,6 +2405,23 @@ __webpack_require__.r(__webpack_exports__);
const ATTACHMENT_TYPE_META = {
image: ['image', '#3ba55c'],
video: ['videocam', '#3b82c4'],
audio: ['audiotrack', '#8b5cf6'],
document: ['description', '#e05d44'],
file: ['insert_drive_file', '#8a8f98']
};
function attachmentTypeFromMime(mime) {
const m = (mime || '').toLowerCase();
if (m.startsWith('image/')) return 'image';
if (m.startsWith('video/')) return 'video';
if (m.startsWith('audio/')) return 'audio';
if (m === 'application/pdf' || m === 'text/plain' || m.includes('msword') || m.includes('wordprocessingml') || m.includes('spreadsheetml') || m.includes('presentationml') || m === 'application/vnd.ms-excel' || m === 'application/vnd.ms-powerpoint') {
return 'document';
}
return 'file';
}
class Attachment extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component) {
constructor(props) {
super(props);
@@ -2469,12 +2493,16 @@ class Attachment extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component
description: "Call to save an attachment"
}, void 0, false)]
}, void 0, true);
const [icon, color] = ATTACHMENT_TYPE_META[attachmentTypeFromMime(this.props.mimetype)];
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "attachment",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons big gray",
children: "insert_drive_file"
className: "material-icons big",
style: {
color
},
children: icon
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "flex-column",
@@ -3584,6 +3612,7 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
this.handleHoldVisibilityChange = this.handleHoldVisibilityChange.bind(this);
this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this);
this.insertEmoji = this.insertEmoji.bind(this);
this.deleteLastChar = this.deleteLastChar.bind(this);
this.toggleAttachPanel = this.toggleAttachPanel.bind(this);
this.openCardPicker = this.openCardPicker.bind(this);
this.openFavPicker = this.openFavPicker.bind(this);
@@ -3888,6 +3917,35 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
}
});
}
deleteLastChar() {
const ta = this.messageEditArea;
const start = ta ? ta.selectionStart : this.state.message.length;
const end = ta ? ta.selectionEnd : this.state.message.length;
let newMessage, caretPos;
if (start !== end) {
newMessage = this.state.message.slice(0, start) + this.state.message.slice(end);
caretPos = start;
} else if (start > 0) {
const before = Array.from(this.state.message.slice(0, start));
before.pop();
newMessage = before.join('') + this.state.message.slice(start);
caretPos = before.join('').length;
} else {
return;
}
this.setState({
message: newMessage
});
if (this.props.onMessageChange) {
this.props.onMessageChange(newMessage);
}
requestAnimationFrame(() => {
if (this.messageEditArea) {
this.messageEditArea.focus();
this.messageEditArea.setSelectionRange(caretPos, caretPos);
}
});
}
toggleAttachPanel(e) {
e.preventDefault();
this.setState(s => ({
@@ -3925,7 +3983,7 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
const build = avatarUrl => {
const card = new tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.TheCard(item.display_name || item.user_id, avatarUrl);
tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.TheCard.setTinodeID(card, item.user_id);
this.props.onSendMessage(tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.Drafty.appendTheCard(null, card));
this.props.onSendMessage(tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.Drafty.appendTheCard(null, card), undefined, undefined, 'card');
};
if (item.avatar_file_id) {
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_6__.getFileUrl)(item.avatar_file_id).then(build).catch(() => build(undefined));
@@ -4217,7 +4275,33 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
},
children: emoji
}, emoji + i, false))
}, void 0, false)]
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
id: "emoji-panel-footer",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("a", {
href: "#",
onClick: e => {
e.preventDefault();
this.deleteLastChar();
},
title: "删除",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("i", {
className: "material-icons",
children: "backspace"
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("a", {
href: "#",
className: 'emoji-panel-send' + (this.state.message ? ' active' : ''),
onClick: e => {
e.preventDefault();
this.handleSend(e);
},
title: formatMessage(messages.icon_title_send),
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("i", {
className: "material-icons",
children: sendIcon
}, void 0, false)
}, void 0, false)]
}, void 0, true)]
}, void 0, true) : null, this.state.attachOpen ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
id: "attach-panel",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("a", {
File diff suppressed because one or more lines are too long
+33 -23
View File
@@ -194,6 +194,9 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
}
onInfo(info) {
if (info.what != 'call') {
if (this.previousOnInfo) {
this.previousOnInfo(info);
}
return;
}
switch (info.event) {
@@ -730,28 +733,32 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
const minimizedClass = this.props.minimized ? 'minimized' : null;
const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit';
if (this.props.minimized && this.state.audioOnly) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container",
className: "minimized audio-minimized",
ref: this.containerRef,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-pill draggable",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: "call"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
className: "call-pill-duration",
children: formatElapsedTime(this.state.elapsedSeconds)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "full-screen",
onClick: this.props.onToggleMinimize,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: fullScreen
}, void 0, false)
}, void 0, false)]
}, void 0, true)
}, void 0, false);
return ((0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container",
className: "minimized audio-minimized",
ref: this.containerRef,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-pill-row",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-pill draggable",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: "call"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
className: "call-pill-duration",
children: formatElapsedTime(this.state.elapsedSeconds)
}, void 0, false)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "full-screen",
onClick: this.props.onToggleMinimize,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: fullScreen
}, void 0, false)
}, void 0, false)]
}, void 0, true)
}, void 0, false)
);
}
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container",
@@ -808,7 +815,10 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "caller-name",
children: peerTitle
}, void 0, false)]
}, void 0, false), !connected ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("p", {
className: "caller-status",
children: "正在等待对方接受邀请…"
}, void 0, false) : null]
}, void 0, true)]
}, void 0, true)
}, void 0, false)]
File diff suppressed because one or more lines are too long