Files
im_webapp/web/src/features/settings/PrivacySettingsPage.tsx
T
Your NameandClaude Sonnet 5 97e8d7144f 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>
2026-09-16 12:46:48 +08:00

167 lines
8.3 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { getBlocks } from '@/lib/api/friends';
import { getMyPrivacy, updateMyPrivacy, type AddMeMethods } from '@/lib/api/profile';
const ADD_ME_LABELS: { key: keyof AddMeMethods; label: string }[] = [
{ key: 'account', label: '通过用户 ID' },
{ key: 'phone', label: '通过手机号' },
{ key: 'qr', label: '通过二维码' },
{ key: 'group', label: '通过群聊' },
];
const VISIBILITY_OPTIONS = [
{ value: 'everyone', label: '所有人' },
{ value: 'friends', label: '仅好友' },
{ value: 'nobody', label: '不展示' },
];
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
</button>
);
}
/**
* screens/07-profile-settings.dc.html#1c: 添加我的方式 (4 toggles, ungrouped -- no dividers between
* them, just the section label above) sits in its own card; 黑名单 sits in a second card alone
* (that mockup also puts "加我为好友时需要验证" there, but that toggle has NO backing field anywhere
* in this response shape or the group-invite backend logic -- confirmed absent, not built). The
* visibility/read-receipts rows below have no mockup at all (added once the real backend fields were
* found during research) -- given their own third card rather than folding them into either of the
* above, so a real capability the design never anticipated doesn't look like it was crammed in.
*/
export function PrivacySettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = useState('');
const query = useQuery({ queryKey: ['my-privacy'], queryFn: getMyPrivacy });
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const mutation = useMutation({
mutationFn: (patch: Parameters<typeof updateMyPrivacy>[0]) => updateMyPrivacy(patch),
// Merge, don't replace -- the doc's own worked example for this exact endpoint (line 2494)
// returns a PARTIAL object (`{add_me_methods,read_receipts,version,updated_at}`, missing
// `profile_visibility`/`last_seen_visibility`/`editable`). A blind replace would erase those
// from the cache after every single save, most importantly `editable`: once it silently
// disappeared, `const editable = query.data.editable || {}` re-derives an EMPTY object, which
// re-enables every field a tenant policy had actually locked, until an unrelated refetch happens
// to restore it.
onSuccess: (data) => queryClient.setQueryData(['my-privacy'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)),
// 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'>>) => {
if (!query.data) return;
mutation.mutate({ ...partial, version: query.data.version });
};
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
// `isLoading` alone used to gate this: it turns false once the fetch SETTLES, including into an
// error, but `query.data` stays undefined either way -- the page was falling through to the same
// "加载中…" text forever on a genuine failure (a slow/dropped connection, not just "still loading"),
// with no retry affordance at all. Same fix already applied to SystemMessagesView's own query.
if (query.isError || !query.data) {
return (
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
);
}
const editable = query.data.editable || {};
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-5 text-xl font-semibold text-text-heading">隐私</h2>
<div className="flex flex-col gap-5">
<div className="rounded-xl bg-white text-sm">
<div className="px-7 pb-1.5 pt-4 text-[13px] text-text-tertiary-2">添加我的方式</div>
{ADD_ME_LABELS.map((item, i) => (
<div key={item.key} className={`flex items-center justify-between px-7 py-3 ${i === ADD_ME_LABELS.length - 1 ? 'pb-4.5' : ''}`}>
<span className="text-text-body">{item.label}</span>
<Toggle
checked={query.data.add_me_methods[item.key]}
disabled={editable.add_me_methods === false || mutation.isPending}
onChange={() => patch({ add_me_methods: { [item.key]: !query.data.add_me_methods[item.key] } })}
/>
</div>
))}
</div>
<button type="button" onClick={() => navigate('/contacts/blocklist')} className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm">
<span className="text-text-body">黑名单</span>
<span className="flex items-center gap-1 text-text-tertiary-2">
{blocksQuery.data?.length ?? 0} 人
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</span>
</button>
<div className="rounded-xl bg-white text-sm">
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
<span className="text-text-body">资料可见范围</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{VISIBILITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={editable.profile_visibility === false || mutation.isPending}
onClick={() => patch({ profile_visibility: opt.value })}
className={`px-3 py-1.5 ${query.data.profile_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
<span className="text-text-body">最近在线可见范围</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{VISIBILITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={editable.last_seen_visibility === false || mutation.isPending}
onClick={() => patch({ last_seen_visibility: opt.value })}
className={`px-3 py-1.5 ${query.data.last_seen_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between px-7 py-4">
<span className="text-text-body">已读回执</span>
<Toggle
checked={query.data.read_receipts}
disabled={editable.read_receipts === false || mutation.isPending}
onChange={() => patch({ read_receipts: !query.data.read_receipts })}
/>
</div>
</div>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}