第六轮深度测试: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:
co-authored by
Claude Sonnet 5
parent
d42591284f
commit
f42b8536e5
@@ -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;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+6
-4
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> :
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
Reference in New Issue
Block a user