- 头像上传从未带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>
167 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|