diff --git a/src/views/contact-management-view.jsx b/src/views/contact-management-view.jsx index fc26c7ec..db10b094 100644 --- a/src/views/contact-management-view.jsx +++ b/src/views/contact-management-view.jsx @@ -393,6 +393,20 @@ function Profile({uid, friend, onTopicSelected, onToast}) { }); }); }, [uid]); + // group-management-view.jsx 的 navigateToProfile 在跳转之后异步用 getGroupMemberProfile 把 + // capabilities/relationship 补进种子缓存(那两个字段 getUserProfile 对非好友会 403 拿不到, + // 群场景权限更宽松能拿到)。这个组件已经挂载了,不会自己重新读缓存,靠这个事件通知它有更新。 + React.useEffect(() => { + if (!uid) return undefined; + const onSeedUpdated = e => { + if (e.detail && e.detail.uid === uid) { + const updated = readCachedData('profile-seed:' + uid); + if (updated) setProfile(updated); + } + }; + window.addEventListener('profile-seed-updated', onSeedUpdated); + return () => window.removeEventListener('profile-seed-updated', onSeedUpdated); + }, [uid]); if (!profile) return

{loadError ? '资料加载失败,请重试' : '加载中…'}

; const canAdd = profile.capabilities && profile.capabilities.can_add_friend; const isFriend = profile.relationship === 'friend'; diff --git a/src/views/group-management-view.jsx b/src/views/group-management-view.jsx index 06762cc9..eaefd21c 100644 --- a/src/views/group-management-view.jsx +++ b/src/views/group-management-view.jsx @@ -9,7 +9,8 @@ import { acceptGroupJoinRequest, rejectGroupJoinRequest, getLatestGroupAnnouncement, updateCurrentGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins, deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner, - getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile + getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile, + getGroupMemberProfile } from '../lib/im-api.js'; import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js'; @@ -28,7 +29,12 @@ const navigate = (view, topic) => { // 因为 GET /users/{id}/profile 对非好友按隐私默认直接 403(imPrivacyAllowsProfile)。群成员里 // 大部分不是好友,不 seed 的话点进去就是"资料加载失败,请重试"。第5章自己的入口 // (contact-management-view.jsx 的 seedProfile)一直是这么做的,这里漏了。 -const navigateToProfile = (userId, member) => { +const navigateToProfile = (topic, userId, member) => { + // 先用群成员列表里已有的字段摆一个立即可用的种子(避免导航瞬间空白), + // 但这份种子缺 capabilities/relationship——GET /groups/{topic}/members 从不返回这两个字段 + // (已实测确认),Profile 组件正是靠它们判断"要不要显示添加好友按钮"和"该显示哪句失败文案"。 + // 缺了它们,非好友的群成员主页会落到 cannotAddReason 的默认分支——不管真实关系是什么, + // 一律显示"好友申请正在等待验证",添加好友按钮永远不出现。 if (member && userId) { writeCachedData('profile-seed:' + userId, { user_id: userId, @@ -41,6 +47,17 @@ const navigateToProfile = (userId, member) => { } HashNavigation.navigateTo( HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId)); + // 群场景下真正该用的是 GET /groups/{topic}/members/{user_id}/profile——群上下文里权限判断更 + // 宽松,实测对方哪怕不是好友也能拿到 {capabilities, relationship}(Profile 组件目标页读的 + // GET /users/{id}/profile 对非好友是 403,只能回退用种子)。异步补种:请求回来后如果用户还 + // 停在这个人的资料页(没有连点跳到别处),用真实数据覆盖种子并强制该页面重新读取。 + if (topic && userId) { + getGroupMemberProfile(topic, userId).then(real => { + const current = readCachedData('profile-seed:' + userId) || {}; + writeCachedData('profile-seed:' + userId, {...current, ...real}); + window.dispatchEvent(new CustomEvent('profile-seed-updated', {detail: {uid: userId}})); + }).catch(() => {}); + } }; export const GROUP_MANAGEMENT_VIEWS = [ @@ -425,7 +442,7 @@ function GroupSettings({topic, myUserId, tinode, onToast}) { 群成员共 {group.member_count} 位成员
- {members.slice(0, 9).map(item => )} \n

{title}

\n \n ;\n}\n\nfunction Field({icon, action, error, className = '', ...props}) {\n return
\n
\n {icon ? {icon} : null}\n \n {action}\n
\n {error ?
{error}
: null}\n
;\n}\n\nfunction PasswordField(props) {\n const [visible, setVisible] = React.useState(false);\n return setVisible(!visible)}\n aria-label={visible ? '隐藏密码' : '显示密码'}>{visible ? 'visibility' : 'visibility_off'}} />;\n}\n\nfunction Agreement({checked, onChange, prefix = '同意捷信密使'}) {\n return ;\n}\n\nfunction Toast({message, onDone}) {\n React.useEffect(() => {\n if (!message) return undefined;\n const timer = window.setTimeout(onDone, 2800);\n return () => window.clearTimeout(timer);\n }, [message, onDone]);\n return message ?
check_circle{message}
: null;\n}\n\n// Shared alert for errors coming back from a real API/WS call (as opposed to local form validation,\n// which each form already reports inline per-field).\nfunction FormAlert({text}) {\n return text ?
error_outline{text}
: null;\n}\n\nfunction TenantEntry({onTenantResolved}) {\n const [code, setCode] = React.useState(sessionStorage.getItem('im-tenant-code') || '');\n const [error, setError] = React.useState('');\n const [busy, setBusy] = React.useState(false);\n\n const submit = async e => {\n e.preventDefault();\n const value = code.trim();\n if (!value || value.length > 64) {\n setError(value ? '企业码不能超过 64 个字符' : '请输入企业码');\n return;\n }\n setBusy(true);\n setError('');\n try {\n const data = await resolveTenant(value);\n // Kept for the mock UI's own display of the tenant name while on this screen; the durable\n // context (read by every subsequent /v1/im/* call and by the {hi} tenant field) is persisted\n // by onTenantResolved.\n sessionStorage.setItem('im-tenant-code', data.tenant.code || value);\n sessionStorage.setItem('im-tenant-name', data.tenant.name || data.tenant.code || value);\n onTenantResolved(data, value);\n navigate('login');\n } catch (err) {\n // Per the product spec, tenant-resolve failures are shown as one generic message\n // regardless of reason (not found vs. disabled vs. rate limited).\n setError('企业码不存在或已停用,请核实后重试');\n } finally {\n setBusy(false);\n }\n };\n\n return
\n \n
\n \n { setCode(e.target.value); setError(''); }}\n placeholder=\"请输入企业码\" autoComplete=\"organization\" maxLength=\"64\" error={error} autoFocus />\n \n \n \n
;\n}\n\nfunction Login({tenantName, login, loginDisabled, persist, errorText, errorLevel, onLoginRequest, onPersistenceChange, onError}) {\n const [account, setAccount] = React.useState(login || '');\n const [password, setPassword] = React.useState('');\n const [agreed, setAgreed] = React.useState(false);\n\n // The prototype (docs/images/登录页面.png) has no \"stay logged in\" checkbox -- a consumer IM app\n // just keeps you signed in. Opt in silently instead of exposing the old debug-era toggle.\n React.useEffect(() => {\n if (!persist) {\n onPersistenceChange(true);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Real login failures (wrong password, disabled account, ...) clear the password and keep the account,\n // matching the product spec.\n React.useEffect(() => {\n if (errorLevel === 'err' && errorText) {\n setPassword('');\n }\n }, [errorText, errorLevel]);\n\n const submit = e => {\n e.preventDefault();\n if (!account.trim() || !password || !agreed || loginDisabled) return;\n onLoginRequest(account.trim(), password);\n };\n\n return
\n \n
\n \n \n setAccount(e.target.value)}\n placeholder=\"请输入用户名/手机号\" autoComplete=\"username\" />\n setPassword(e.target.value)}\n placeholder=\"请输入密码\" autoComplete=\"current-password\" />\n
\n \n

没有账户?

\n \n \n
;\n}\n\nfunction EnterpriseRegister() {\n const [creditCode, setCreditCode] = React.useState('');\n const [email, setEmail] = React.useState('');\n const [file, setFile] = React.useState(null);\n const [errors, setErrors] = React.useState({});\n const [submitting, setSubmitting] = React.useState(false);\n const [submitted, setSubmitted] = React.useState(false);\n const [reviewDays, setReviewDays] = React.useState(1);\n const [toast, setToast] = React.useState('');\n\n React.useEffect(() => () => file && URL.revokeObjectURL(file.url), [file]);\n\n const chooseFile = e => {\n const selected = e.target.files[0];\n if (!selected) return;\n if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) {\n setErrors({...errors, file: '支持 JPG、PNG、WebP、PDF,文件不超过 10MB'});\n return;\n }\n if (file) URL.revokeObjectURL(file.url);\n setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected), blob: selected});\n setErrors({...errors, file: ''});\n };\n\n const submit = async e => {\n e.preventDefault();\n const next = {};\n if (!CREDIT_CODE_RE.test(creditCode)) next.creditCode = '请输入 18 位统一社会信用代码';\n if (email && !EMAIL_RE.test(email)) next.email = '请输入有效的企业邮箱';\n setErrors(next);\n if (Object.keys(next).length) return;\n\n setSubmitting(true);\n try {\n let licenseFileId = '';\n if (file) {\n const uploaded = await uploadPublicFile(file.blob, 'enterprise_license');\n licenseFileId = uploaded.file_id;\n }\n const data = await submitTenantApplication({\n credit_code: creditCode,\n enterprise_email: email,\n license_file_id: licenseFileId,\n tenant_code_hint: sessionStorage.getItem('im-tenant-code') || ''\n });\n // Prototype (docs/images/企业注册-填写注册信息.png) states this in business days, not hours.\n setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24)));\n setSubmitted(true);\n setToast('提交成功,我们会尽快联系你');\n } catch (err) {\n setErrors({...next, form: err.message || '提交失败,请稍后重试'});\n } finally {\n setSubmitting(false);\n }\n };\n\n return
\n
\n
\n
    \n
  1. 1填写企业信息
  2. \n
  3. 2确定信息
  4. \n
  5. 3提交审核
  6. \n
\n
\n \n \n setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}\n placeholder=\"请输入18位统一社会信用代码\" maxLength=\"18\" icon=\"qr_code_scanner\" error={errors.creditCode} />\n

请填写营业执照上的统一社会信用代码

\n\n \n \n {file ? : null}\n {errors.file ?
{errors.file}
: null}\n\n \n setEmail(e.target.value)}\n placeholder=\"请输入企业邮箱\" type=\"email\" error={errors.email} />\n

用于接收审核结果及企业通知

\n \n

schedule资料提交后,预计 {reviewDays} 个工作日完成审核

\n \n
\n setToast('')} />\n
;\n}\n\n// Per docs/images/企业注册-问题反馈.png: titled \"意见反馈\", 300-char textarea with the instructions\n// as its placeholder (not a separate label), \"图片(N/9)\" counter-style label, and no contact field.\nfunction EnterpriseFeedback() {\n const [description, setDescription] = React.useState('');\n const [files, setFiles] = React.useState([]);\n const [error, setError] = React.useState('');\n const [submitting, setSubmitting] = React.useState(false);\n const [toast, setToast] = React.useState('');\n\n const addFiles = e => {\n const selected = Array.from(e.target.files || []).slice(0, 9 - files.length);\n const valid = selected.filter(file => file.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(file.type));\n setFiles(files.concat(valid.map(file => ({name: file.name, url: URL.createObjectURL(file), blob: file}))));\n };\n const submit = async e => {\n e.preventDefault();\n const length = description.trim().length;\n if (length < 10 || length > 300) {\n setError('问题描述需为 10 至 300 个字符');\n return;\n }\n setSubmitting(true);\n try {\n const screenshotFileIds = [];\n for (const item of files) {\n const uploaded = await uploadPublicFile(item.blob, 'tenant_entry_feedback');\n screenshotFileIds.push(uploaded.file_id);\n }\n await submitTenantEntryFeedback({\n description: description.trim(),\n tenant_code_hint: sessionStorage.getItem('im-tenant-code') || '',\n screenshot_file_ids: screenshotFileIds\n });\n setToast('已收到反馈,我们会尽快处理');\n files.forEach(item => URL.revokeObjectURL(item.url));\n setDescription(''); setFiles([]); setError('');\n } catch (err) {\n setError(err.message || '提交失败,请稍后重试');\n } finally {\n setSubmitting(false);\n }\n };\n\n return
\n
\n
\n
\n \n