fix(web): 第9轮审查批次4——设置域一批问题
- 头像上传从未带purpose=user_avatar:之前是照抄一个已经修复的旧后端 bug(问题28)绕开的写法,现在确认接口早就支持这个purpose了,补上—— uploadFile()新增可选purpose参数,不影响其它已有调用点(聊天附件/群 文件继续用topic的chat_file路径) - 桌面卡片行分隔线6处错用了移动端专属的border-list-item token,改成 桌面正确的border-row - 退出登录按钮外边距/设置目录导航图标尺寸(18px→20px)与设计稿对齐 - 地区字段补上maxLength兜底 - 修改密码没有用<form>包裹,输入框按回车没反应——补上表单+onSubmit - 记录一条此前没进清单的后端缺口:"加我为好友时需要验证"这个隐私开关 设计稿画了,但响应体里根本没有对应字段 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
18d21a03bb
commit
97e8d7144f
@@ -62,7 +62,12 @@ export function PrivacySettingsPage() {
|
|||||||
// re-enables every field a tenant policy had actually locked, until an unrelated refetch happens
|
// re-enables every field a tenant policy had actually locked, until an unrelated refetch happens
|
||||||
// to restore it.
|
// to restore it.
|
||||||
onSuccess: (data) => queryClient.setQueryData(['my-privacy'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)),
|
onSuccess: (data) => queryClient.setQueryData(['my-privacy'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)),
|
||||||
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
|
// Same reasoning as ProfileSettingsPage's own saveMutation: a version conflict shouldn't leave
|
||||||
|
// the cached `version` stale, or every retry just resends the same rejected value forever.
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err instanceof Error ? err.message : '设置失败');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['my-privacy'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const patch = (partial: Partial<Omit<Parameters<typeof updateMyPrivacy>[0], 'version'>>) => {
|
const patch = (partial: Partial<Omit<Parameters<typeof updateMyPrivacy>[0], 'version'>>) => {
|
||||||
if (!query.data) return;
|
if (!query.data) return;
|
||||||
@@ -113,7 +118,7 @@ export function PrivacySettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="rounded-xl bg-white text-sm">
|
<div className="rounded-xl bg-white text-sm">
|
||||||
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
|
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
|
||||||
<span className="text-text-body">资料可见范围</span>
|
<span className="text-text-body">资料可见范围</span>
|
||||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||||
{VISIBILITY_OPTIONS.map((opt) => (
|
{VISIBILITY_OPTIONS.map((opt) => (
|
||||||
@@ -129,7 +134,7 @@ export function PrivacySettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
|
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
|
||||||
<span className="text-text-body">最近在线可见范围</span>
|
<span className="text-text-body">最近在线可见范围</span>
|
||||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||||
{VISIBILITY_OPTIONS.map((opt) => (
|
{VISIBILITY_OPTIONS.map((opt) => (
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ const InlineField = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* README §8.2 / screens/07-profile-settings.dc.html#1a. Avatar upload reuses upload.ts's native
|
* README §8.2 / screens/07-profile-settings.dc.html#1a. Avatar upload reuses upload.ts's native
|
||||||
* `/v0/file/u/` channel, NOT `POST /v1/im/public/uploads?purpose=user_avatar` -- confirmed
|
* `/v0/file/u/` channel (the one every other upload in this app already goes through), NOT
|
||||||
* live-broken (docs/IM-后端问题清单.md 问题28: that endpoint 400s on `purpose=user_avatar`
|
* `POST /v1/im/public/uploads` -- that's a different, pre-login-only channel for enterprise
|
||||||
* specifically), and the old app's own real avatar-upload code never goes through it either.
|
* registration/tenant feedback (`lib/api/tenant.ts`'s `uploadPublicFile`, whose own `purpose` type
|
||||||
|
* is explicitly `'enterprise_license' | 'tenant_entry_feedback'`), never meant for an authenticated
|
||||||
|
* user's own avatar. Passes `purpose: 'user_avatar'` explicitly per docs/IM-前后端对接文档.md:2293
|
||||||
|
* -- this used to be omitted entirely (traced to `purpose=user_avatar` 400ing on THIS channel too,
|
||||||
|
* docs/IM-后端问题清单.md 问题28, since confirmed fixed: a real PNG upload with that purpose now
|
||||||
|
* returns 200).
|
||||||
*/
|
*/
|
||||||
export function ProfileSettingsPage() {
|
export function ProfileSettingsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -79,12 +84,19 @@ export function ProfileSettingsPage() {
|
|||||||
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
||||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
},
|
},
|
||||||
onError: (err) => setError(err instanceof Error ? err.message : '保存失败'),
|
// IM-前后端对接文档.md 10.3 (版本冲突先拉最新资料再由用户确认): a stale `version` (e.g. edited
|
||||||
|
// on another tab/device) 400s here, but the cached `query.data.version` used to just sit there
|
||||||
|
// unchanged -- every retry would resend the exact same stale version and 400 again forever, the
|
||||||
|
// toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched.
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err instanceof Error ? err.message : '保存失败');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['my-profile'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const avatarMutation = useMutation({
|
const avatarMutation = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
const uploaded = await uploadFile(file);
|
const uploaded = await uploadFile(file, undefined, 'user_avatar');
|
||||||
if (!query.data) throw new Error('尚未加载');
|
if (!query.data) throw new Error('尚未加载');
|
||||||
return updateMyProfile({ avatar_file_id: uploaded.file_id, version: query.data.version });
|
return updateMyProfile({ avatar_file_id: uploaded.file_id, version: query.data.version });
|
||||||
},
|
},
|
||||||
@@ -172,7 +184,7 @@ export function ProfileSettingsPage() {
|
|||||||
{showQrCode ? <QRCodeModal onClose={() => setShowQrCode(false)} /> : null}
|
{showQrCode ? <QRCodeModal onClose={() => setShowQrCode(false)} /> : null}
|
||||||
|
|
||||||
<div className="rounded-xl bg-white text-sm">
|
<div className="rounded-xl bg-white text-sm">
|
||||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||||
<span className="text-text-secondary">昵称</span>
|
<span className="text-text-secondary">昵称</span>
|
||||||
<InlineField
|
<InlineField
|
||||||
value={draft.nickname}
|
value={draft.nickname}
|
||||||
@@ -182,7 +194,7 @@ export function ProfileSettingsPage() {
|
|||||||
<Icon name="edit" className="text-[20px] text-icon-arrow" />
|
<Icon name="edit" className="text-[20px] text-icon-arrow" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||||
<span className="text-text-secondary">性别</span>
|
<span className="text-text-secondary">性别</span>
|
||||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs" style={{ width: 'fit-content' }}>
|
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs" style={{ width: 'fit-content' }}>
|
||||||
{GENDER_OPTIONS.map((opt) => (
|
{GENDER_OPTIONS.map((opt) => (
|
||||||
@@ -199,7 +211,7 @@ export function ProfileSettingsPage() {
|
|||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||||
<span className="text-text-secondary">出生日期</span>
|
<span className="text-text-secondary">出生日期</span>
|
||||||
<InlineField
|
<InlineField
|
||||||
ref={birthDateRef}
|
ref={birthDateRef}
|
||||||
@@ -213,9 +225,9 @@ export function ProfileSettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||||
<span className="text-text-secondary">地区</span>
|
<span className="text-text-secondary">地区</span>
|
||||||
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" />
|
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" maxLength={50} />
|
||||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,14 @@ export function SecuritySettingsPage() {
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="rounded-xl bg-white text-sm">
|
<div className="rounded-xl bg-white text-sm">
|
||||||
{changingPassword ? (
|
{changingPassword ? (
|
||||||
<div className="flex flex-col gap-3 px-7 py-4">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPassword.length === 0 || newPassword.length < minPasswordLength || passwordsMismatch || confirmPassword.length === 0 || passwordMutation.isPending) return;
|
||||||
|
passwordMutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3 px-7 py-4"
|
||||||
|
>
|
||||||
<span className="text-text-body">修改密码</span>
|
<span className="text-text-body">修改密码</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -139,8 +146,7 @@ export function SecuritySettingsPage() {
|
|||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={() => passwordMutation.mutate()}
|
|
||||||
disabled={
|
disabled={
|
||||||
currentPassword.length === 0 ||
|
currentPassword.length === 0 ||
|
||||||
newPassword.length < minPasswordLength ||
|
newPassword.length < minPasswordLength ||
|
||||||
@@ -153,7 +159,7 @@ export function SecuritySettingsPage() {
|
|||||||
{passwordMutation.isPending ? '保存中…' : '保存'}
|
{passwordMutation.isPending ? '保存中…' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={() => setChangingPassword(true)} className="flex w-full items-center justify-between px-7 py-4 text-left">
|
<button type="button" onClick={() => setChangingPassword(true)} className="flex w-full items-center justify-between px-7 py-4 text-left">
|
||||||
<span className="text-text-body">修改密码</span>
|
<span className="text-text-body">修改密码</span>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function SettingsLayout() {
|
|||||||
<header className="flex h-15 flex-none items-center px-5">
|
<header className="flex h-15 flex-none items-center px-5">
|
||||||
<h1 className="m-0 text-xl font-semibold text-text-heading">设置</h1>
|
<h1 className="m-0 text-xl font-semibold text-text-heading">设置</h1>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex items-center gap-3 border-b border-border-list-item px-5 pb-4.5 pt-2">
|
<div className="flex items-center gap-3 border-b border-border-row px-5 pb-4.5 pt-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
id={meQuery.data?.user_id || ''}
|
id={meQuery.data?.user_id || ''}
|
||||||
name={meQuery.data?.profile.nickname}
|
name={meQuery.data?.profile.nickname}
|
||||||
@@ -113,7 +113,7 @@ export function SettingsLayout() {
|
|||||||
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon name={item.icon} className="text-[18px]" />
|
<Icon name={item.icon} className="text-[20px]" />
|
||||||
<span className="flex-1">{item.label}</span>
|
<span className="flex-1">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -123,7 +123,7 @@ export function SettingsLayout() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
disabled={loggingOut}
|
disabled={loggingOut}
|
||||||
className="mx-3 my-4 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
|
className="mx-5 mt-auto mb-5 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
退出登录
|
退出登录
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -36,13 +36,22 @@ const UPLOAD_CTRL_TEXT_CN: Record<string, string> = {
|
|||||||
'not found': '内容不存在或已被删除',
|
'not found': '内容不存在或已被删除',
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function uploadFile(file: File, topic?: string): Promise<UploadedFile> {
|
/**
|
||||||
|
* `purpose` only matters when `topic` is omitted (a chat/group-file upload always sends
|
||||||
|
* `chat_file` regardless) -- currently just the avatar upload path, which used to send no
|
||||||
|
* `purpose` at all. That omission dated back to this same endpoint's `purpose=user_avatar` 400ing
|
||||||
|
* (docs/IM-后端问题清单.md 问题28) -- confirmed since fixed (a real PNG upload with that purpose
|
||||||
|
* now returns 200), so there's no reason left to omit it.
|
||||||
|
*/
|
||||||
|
export async function uploadFile(file: File, topic?: string, purpose?: string): Promise<UploadedFile> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
form.set('id', getTinode().getNextUniqueId());
|
form.set('id', getTinode().getNextUniqueId());
|
||||||
if (topic) {
|
if (topic) {
|
||||||
form.append('purpose', 'chat_file');
|
form.append('purpose', 'chat_file');
|
||||||
form.append('topic', topic);
|
form.append('topic', topic);
|
||||||
|
} else if (purpose) {
|
||||||
|
form.append('purpose', purpose);
|
||||||
}
|
}
|
||||||
const res = await fetch(`${fileBase()}/u/`, {
|
const res = await fetch(`${fileBase()}/u/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user