feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group management (admin panel, mute, files, announcements, QR-based invite/join), contacts (friend profile, add-friend, blocklist, new-friends), favorites (real media rendering across text/image/video/voice/file/link), settings (profile/security/privacy/general/about), calls (Agora-backed audio/video signaling + call history), and system messages/file assistant. Also folds in a large batch of live-verified fixes surfaced by repeated fresh code-review passes and hands-on E2E testing against the real backend, including: WS reconnect not resubscribing any topic after a disconnect, missing x-im-client-mid on every send path (protocol compliance for retry dedup), group mute/member-settings/self-profile endpoint corrections, relationship-state type fixes across search/QR/ friend-profile, tenant-switch logout gap, password-policy now read from the real per-tenant config instead of guessed, NavRail highlight/ aria-current desync, several call-state-machine races, and responsive layout fixes across all four breakpoints (mobile/tablet/laptop/desktop) including a full mobile rebuild of favorites, call UI, and the settings home/sub-list split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -0,0 +1,47 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { getLatestRelease } from '@/lib/api/app';
|
||||
|
||||
const CURRENT_VERSION = __APP_VERSION__;
|
||||
|
||||
/**
|
||||
* README §8.7's "v0.25 · 有新版本" row. Confirmed live (2026-09-08): `platform:"web"` is REJECTED
|
||||
* outright -- `400 MALFORMED_REQUEST {"details":{"field":"platform"}}` -- the real backend only
|
||||
* accepts the doc's own example values (`ios`/`android`), no web support at all. Passing a wrong
|
||||
* platform to get SOME answer would be dishonest (it'd be checking updates for the wrong client),
|
||||
* so this shows an honest "not supported" state instead of pretending the check succeeded.
|
||||
*/
|
||||
export function AboutPage() {
|
||||
const checkMutation = useMutation({ mutationFn: () => getLatestRelease(CURRENT_VERSION) });
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-4 text-xl font-semibold text-text-heading">关于 MeetingIM</h2>
|
||||
<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">
|
||||
<span className="text-text-body">当前版本</span>
|
||||
<span className="text-text-tertiary-2">{CURRENT_VERSION}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={checkMutation.isPending}
|
||||
className="flex w-full items-center justify-between px-7 py-4 text-left"
|
||||
>
|
||||
<span className="text-text-body">检查更新</span>
|
||||
<span className="text-text-tertiary-2">
|
||||
{checkMutation.isPending
|
||||
? '检查中…'
|
||||
: checkMutation.isError
|
||||
? '暂不支持检查更新'
|
||||
: checkMutation.data
|
||||
? checkMutation.data.update_type === 'none'
|
||||
? '已是最新版本'
|
||||
: `发现新版本 ${checkMutation.data.latest_version}`
|
||||
: '›'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Toast } from '@/app/Toast';
|
||||
|
||||
/**
|
||||
* README §8.7 mentions a "清空缓存「126 MB」" row -- confirmed no server endpoint touches cache at
|
||||
* all (docs/IM-后端问题清单.md-adjacent research: "No backend endpoint clears cache/history -- both
|
||||
* are pure client-side operations"). Computing a real, honest "just this app's cache" byte count is
|
||||
* not reliably possible (`navigator.storage.estimate()` reports the WHOLE origin, not a
|
||||
* per-feature breakdown) -- rather than show a fabricated-looking number, this omits the size and
|
||||
* just performs and confirms the real clear (this app's own Cache Storage buckets, per the SW-
|
||||
* caching behavior already relied on elsewhere in this app).
|
||||
*/
|
||||
export function GeneralSettingsPage() {
|
||||
const [status, setStatus] = useState<'idle' | 'clearing' | 'done'>('idle');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const clearCache = async () => {
|
||||
if (!window.confirm('确定清除本地缓存吗?不会影响你的账号数据。')) return;
|
||||
setStatus('clearing');
|
||||
try {
|
||||
if (typeof caches === 'undefined') throw new Error('此浏览器不支持缓存清除');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
setStatus('done');
|
||||
} catch (err) {
|
||||
setStatus('idle');
|
||||
setError(err instanceof Error ? err.message : '清除缓存失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-4 text-xl font-semibold text-text-heading">通用</h2>
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<button type="button" onClick={clearCache} disabled={status === 'clearing'} className="flex w-full items-center justify-between px-7 py-4 text-left">
|
||||
<span className="text-text-body">清空缓存</span>
|
||||
<span className="text-text-tertiary-2">{status === 'clearing' ? '清除中…' : status === 'done' ? '已清除 ›' : '›'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* README §8.1's directory lists a dedicated "消息通知" section, but there is no per-type
|
||||
* notification-preference REST endpoint anywhere (confirmed: grepped both protocol-doc copies for
|
||||
* notification_settings/推送偏好, zero hits; the old app's own settings list has no such row
|
||||
* either) -- the doc's own claim that "服务端只保存应用内通知偏好" has nothing backing it. Rather
|
||||
* than build toggles that write nowhere, this only wires the one thing that's real and entirely
|
||||
* client-side: the browser's own Notification permission.
|
||||
*/
|
||||
export function NotificationsSettingsPage() {
|
||||
const [permission, setPermission] = useState<NotificationPermission>(typeof Notification !== 'undefined' ? Notification.permission : 'denied');
|
||||
|
||||
const request = async () => {
|
||||
if (typeof Notification === 'undefined') return;
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
};
|
||||
|
||||
const label = { granted: '已允许', denied: '已拒绝', default: '未设置' }[permission];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-4 text-xl font-semibold text-text-heading">消息通知</h2>
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="flex items-center justify-between px-7 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-body">桌面通知</span>
|
||||
<span className="text-xs text-text-tertiary-3">由浏览器管理,用于在新消息到达时提醒你</span>
|
||||
</div>
|
||||
{permission === 'default' ? (
|
||||
<button type="button" onClick={request} className="h-8 flex-none rounded-lg bg-btn px-3 text-xs font-medium text-white">
|
||||
开启
|
||||
</button>
|
||||
) : (
|
||||
<span className={`flex-none text-xs ${permission === 'granted' ? 'text-success' : 'text-text-tertiary-2'}`}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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)),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
|
||||
});
|
||||
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-list-item 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-list-item 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const GENDER_OPTIONS: { value: 'male' | 'female' | 'other'; label: string }[] = [
|
||||
{ value: 'male', label: '男' },
|
||||
{ value: 'female', label: '女' },
|
||||
{ value: 'other', label: '保密' },
|
||||
];
|
||||
const DEFAULT_MAX_SIGNATURE_LENGTH = 60;
|
||||
|
||||
/** Plain-text-looking field: a real `<input>` with all box chrome stripped so it reads as the
|
||||
* design's "label / value / edit-affordance-icon" row instead of a boxed form control, while
|
||||
* staying genuinely editable (click anywhere in the value cell to type). */
|
||||
const InlineField = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(function InlineField(props, ref) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={`w-full border-0 bg-transparent p-0 text-sm text-text-body outline-none [&::-webkit-calendar-picker-indicator]:hidden ${props.className || ''}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* live-broken (docs/IM-后端问题清单.md 问题28: that endpoint 400s on `purpose=user_avatar`
|
||||
* specifically), and the old app's own real avatar-upload code never goes through it either.
|
||||
*/
|
||||
export function ProfileSettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const query = useQuery({ queryKey: ['my-profile'], queryFn: getMyProfile });
|
||||
const [draft, setDraft] = useState<{ nickname: string; gender: string; birth_date: string; region: string; signature: string } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [showQrCode, setShowQrCode] = useState(false);
|
||||
const birthDateRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data && !draft) {
|
||||
setDraft({
|
||||
nickname: query.data.nickname,
|
||||
gender: query.data.gender || '',
|
||||
birth_date: query.data.birth_date || '',
|
||||
region: query.data.region || '',
|
||||
signature: query.data.signature || '',
|
||||
});
|
||||
}
|
||||
}, [query.data, draft]);
|
||||
|
||||
// saveMutation and avatarMutation both PATCH the same optimistic-locked resource. The disabled=
|
||||
// props below on the avatar input/save button stop the two from ever being pending at the same
|
||||
// time, closing the race where both could send the SAME stale `query.data.version` and whichever
|
||||
// response resolves last would win the cache. Independent of that: EITHER mutation's own response
|
||||
// can itself be a PARTIAL profile object -- the doc's own worked example for this endpoint (line
|
||||
// 2492) returns only `{avatar_file_id,nickname,signature,version,updated_at}`, omitting
|
||||
// `birth_date`/`gender`/`region`/`public_account`/`field_rules` entirely, and avatarMutation's own
|
||||
// request body is deliberately partial (`{avatar_file_id,version}` only) so its response is the
|
||||
// most likely of the two to come back partial too. A blind `setQueryData(key, data)` replace would
|
||||
// erase those fields from the cache after a single avatar change -- merging onto the existing
|
||||
// cached object (not just overwriting it) is what actually prevents that regardless of exactly how
|
||||
// partial a given response turns out to be.
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!query.data || !draft) throw new Error('尚未加载');
|
||||
return updateMyProfile({ ...draft, version: query.data.version });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '保存失败'),
|
||||
});
|
||||
|
||||
const avatarMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const uploaded = await uploadFile(file);
|
||||
const fileId = uploaded.url.split('/').pop() || '';
|
||||
if (!query.data) throw new Error('尚未加载');
|
||||
return updateMyProfile({ avatar_file_id: fileId, version: query.data.version });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
setUploadingAvatar(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : '头像上传失败');
|
||||
setUploadingAvatar(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handlePickAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('图片大小不能超过 5MB');
|
||||
return;
|
||||
}
|
||||
setUploadingAvatar(true);
|
||||
avatarMutation.mutate(file);
|
||||
};
|
||||
|
||||
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
|
||||
// Same gap as PrivacySettingsPage's own query: `draft` only ever gets set from a successful fetch
|
||||
// (the effect above is gated on `query.data`), so a genuine fetch failure left `isLoading` false
|
||||
// but `draft` permanently null -- falling through to the exact same "加载中…" text forever, with no
|
||||
// way to retry.
|
||||
if (query.isError || !draft) {
|
||||
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 maxSignatureLength = query.data?.field_rules?.signature?.max_length || DEFAULT_MAX_SIGNATURE_LENGTH;
|
||||
const dirty =
|
||||
query.data &&
|
||||
(draft.nickname !== query.data.nickname ||
|
||||
draft.gender !== (query.data.gender || '') ||
|
||||
draft.birth_date !== (query.data.birth_date || '') ||
|
||||
draft.region !== (query.data.region || '') ||
|
||||
draft.signature !== (query.data.signature || ''));
|
||||
|
||||
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="flex items-center gap-5 rounded-xl bg-white px-7 py-6">
|
||||
<div className="relative flex-none">
|
||||
<Avatar id={myUserId || ''} name={draft.nickname} avatarFileId={query.data?.avatar_file_id} className="h-20 w-20 rounded-[20px] text-2xl" />
|
||||
<label className="absolute -bottom-1 -right-1 grid h-[26px] w-[26px] cursor-pointer place-items-center rounded-full bg-white text-text-tertiary-1 shadow-btn-sm">
|
||||
<Icon name="photo_camera" className="text-[15px]" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
onChange={handlePickAvatar}
|
||||
className="hidden"
|
||||
disabled={uploadingAvatar || saveMutation.isPending}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<strong className="truncate text-lg font-semibold text-text-heading">{draft.nickname}</strong>
|
||||
<span className="truncate text-[13px] text-text-tertiary-2">
|
||||
ID:{query.data?.public_account} · {uploadingAvatar ? '上传中…' : '支持 JPG/PNG,不超过 5MB'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQrCode(true)}
|
||||
className="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-[#d5d9df] px-2.5 text-[13px] text-text-tertiary-1 sm:px-3.5"
|
||||
>
|
||||
<Icon name="qr_code_2" className="text-[18px]" />
|
||||
<span className="hidden sm:inline">我的二维码</span>
|
||||
</button>
|
||||
</div>
|
||||
{showQrCode ? <QRCodeModal onClose={() => setShowQrCode(false)} /> : null}
|
||||
|
||||
<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]">
|
||||
<span className="text-text-secondary">昵称</span>
|
||||
<InlineField
|
||||
value={draft.nickname}
|
||||
onChange={(e) => setDraft({ ...draft, nickname: e.target.value })}
|
||||
maxLength={query.data?.field_rules?.nickname?.max_length || 32}
|
||||
/>
|
||||
<Icon name="edit" className="text-[20px] text-icon-arrow" />
|
||||
</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]">
|
||||
<span className="text-text-secondary">性别</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs" style={{ width: 'fit-content' }}>
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, gender: opt.value })}
|
||||
className={`px-3.5 py-1.5 ${draft.gender === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span />
|
||||
</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]">
|
||||
<span className="text-text-secondary">出生日期</span>
|
||||
<InlineField
|
||||
ref={birthDateRef}
|
||||
type="date"
|
||||
value={draft.birth_date}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(e) => setDraft({ ...draft, birth_date: e.target.value })}
|
||||
/>
|
||||
<button type="button" onClick={() => birthDateRef.current?.showPicker?.()} className="grid place-items-center text-icon-arrow">
|
||||
<Icon name="calendar_today" className="text-[20px]" />
|
||||
</button>
|
||||
</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]">
|
||||
<span className="text-text-secondary">地区</span>
|
||||
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" />
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-start gap-x-2 px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="pt-2 text-text-secondary">个性签名</span>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={draft.signature}
|
||||
onChange={(e) => setDraft({ ...draft, signature: e.target.value.slice(0, maxSignatureLength) })}
|
||||
rows={3}
|
||||
className="min-h-[60px] w-full resize-none rounded-lg bg-surface-input p-3 pr-14 text-sm outline-none"
|
||||
/>
|
||||
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
|
||||
{draft.signature.length}/{maxSignatureLength}
|
||||
</span>
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
query.data &&
|
||||
setDraft({
|
||||
nickname: query.data.nickname,
|
||||
gender: query.data.gender || '',
|
||||
birth_date: query.data.birth_date || '',
|
||||
region: query.data.region || '',
|
||||
signature: query.data.signature || '',
|
||||
})
|
||||
}
|
||||
disabled={!dirty}
|
||||
className="h-9.5 rounded-lg border border-[#d5d9df] px-5 text-sm text-text-tertiary-1 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!dirty || saveMutation.isPending || uploadingAvatar}
|
||||
className="h-9.5 rounded-lg bg-btn px-5 text-sm font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
{saveMutation.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getTinode, resetTinode } from '@/lib/tinode/connection';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* screens/07-profile-settings.dc.html#1b. That mockup's 手机号/登录设备 rows have no real data
|
||||
* source and aren't built as decorative fakes: phone number display (no `GET my phone` endpoint
|
||||
* anywhere -- the only place a phone surfaces is a masked hint as a SIDE EFFECT of starting an
|
||||
* unrelated SMS challenge, not a passive read) and the logged-in-device list (only a kick-by-id
|
||||
* DELETE exists, itself marked 待实现, with no GET list endpoint to populate rows from). Password
|
||||
* change and account deletion both go through native Tinode `{acc}`/`{del}` calls (see
|
||||
* tinode-sdk.d.ts's own comments) -- there is no REST path for either, confirmed across both doc
|
||||
* copies and the backend problem list.
|
||||
*/
|
||||
export function SecuritySettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [deletingAccount, setDeletingAccount] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
// Real per-tenant policy (docs/IM-前后端对接文档.md:534, fetched once at tenant-resolve time and
|
||||
// stored on `tenant`) -- was hardcoded to 6 here, silently DIFFERENT from Register/ResetPassword's
|
||||
// own (also-hardcoded, but at least matching each other) 8, so a password this page accepted could
|
||||
// still get rejected by the backend's real policy.
|
||||
const minPasswordLength = tenant?.passwordPolicy?.minLength ?? 8;
|
||||
|
||||
const passwordsMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
|
||||
|
||||
const passwordMutation = useMutation({
|
||||
// `updateAccountBasic`'s own type comment (tinode-sdk.d.ts) already confirms, from this
|
||||
// engagement's earlier research into the old app's real working code: the server does NOT
|
||||
// verify a "current password" for this call at all -- an already-authenticated WS session is
|
||||
// by itself sufficient to change the password to anything. `currentPassword` below is therefore
|
||||
// NOT sent anywhere and is NOT a real credential check -- it exists purely as a client-side "did
|
||||
// you mean to do this" gate (the submit button stays disabled while it's empty), the same
|
||||
// fat-finger/shared-device speed bump most password-change screens have, not a security boundary.
|
||||
// A REAL re-verification would need the protocol's own `POST /security/challenges` sensitive-
|
||||
// action flow, but the only confirmed `scene` values for that endpoint (`set_independent_password`/
|
||||
// `close_youth_mode`) are both youth-mode-specific -- and youth mode was removed from this app
|
||||
// entirely per an earlier explicit user request, so that path isn't available here either.
|
||||
mutationFn: () => {
|
||||
const tinode = getTinode();
|
||||
return tinode.updateAccountBasic(null, tinode.getCurrentLogin(), newPassword);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setChangingPassword(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setSuccess('密码已修改。');
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '密码修改失败'),
|
||||
});
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (deletingAccount) return;
|
||||
if (!window.confirm('注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?')) return;
|
||||
if (!window.confirm('请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。')) return;
|
||||
setDeletingAccount(true);
|
||||
try {
|
||||
await getTinode().delCurrentUser(true);
|
||||
resetTinode();
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
// Deliberately NOT clearTenant() -- see SettingsLayout.tsx's logout for why: it clears
|
||||
// useSessionStore's `tenant`, and LoginPage's own tenant guard immediately bounces navigate(
|
||||
// '/login') below back to the tenant-code entry screen instead of this tenant's login page.
|
||||
//
|
||||
// Same shared-device concern as SettingsLayout's logout -- a next login in this tab should
|
||||
// never briefly render this now-deleted account's cached data.
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '注销失败');
|
||||
setDeletingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
{changingPassword ? (
|
||||
<div className="flex flex-col gap-3 px-7 py-4">
|
||||
<span className="text-text-body">修改密码</span>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="输入当前密码"
|
||||
autoComplete="current-password"
|
||||
className="rounded-md bg-surface-input px-3 py-2 outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={`输入新密码(至少 ${minPasswordLength} 位)`}
|
||||
autoComplete="new-password"
|
||||
maxLength={tenant?.passwordPolicy?.maxLength}
|
||||
className="rounded-md bg-surface-input px-3 py-2 outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="再次输入新密码"
|
||||
autoComplete="new-password"
|
||||
maxLength={tenant?.passwordPolicy?.maxLength}
|
||||
className={`rounded-md bg-surface-input px-3 py-2 outline-none ${passwordsMismatch ? 'border border-danger-text' : ''}`}
|
||||
/>
|
||||
{passwordsMismatch ? <span className="text-xs text-danger-border">两次输入的密码不一致</span> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChangingPassword(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => passwordMutation.mutate()}
|
||||
disabled={
|
||||
currentPassword.length === 0 ||
|
||||
newPassword.length < minPasswordLength ||
|
||||
passwordsMismatch ||
|
||||
confirmPassword.length === 0 ||
|
||||
passwordMutation.isPending
|
||||
}
|
||||
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
{passwordMutation.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteAccount}
|
||||
disabled={deletingAccount}
|
||||
className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm disabled:opacity-50"
|
||||
>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-text-body">注销账号</span>
|
||||
<span className="text-xs text-text-tertiary-2">{deletingAccount ? '注销中…' : '注销后数据不可恢复'}</span>
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
<Toast message={success} variant="success" onDone={() => setSuccess('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage';
|
||||
import { useMediaQuery } from '@/lib/hooks';
|
||||
|
||||
/**
|
||||
* `/settings` alone has no natural "empty" content the way contacts/messages do -- on desktop
|
||||
* (directory + content docked side by side) the most-relevant first section fills the content pane
|
||||
* directly. On narrow viewports this must NOT redirect there (an earlier version did): SettingsLayout
|
||||
* only shows the directory list at this exact bare path, and a redirect away from it would bounce
|
||||
* straight past that list before a phone-width visitor ever saw it -- so this renders nothing there,
|
||||
* leaving the directory (rendered by the parent layout) as the whole screen.
|
||||
*/
|
||||
export function SettingsHome() {
|
||||
const isDesktop = useMediaQuery('(min-width: 960px)');
|
||||
return isDesktop ? <ProfileSettingsPage /> : null;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const DIRECTORY = [
|
||||
{ key: 'profile', to: '/settings/profile', icon: 'account_circle', label: '个人资料' },
|
||||
{ key: 'security', to: '/settings/security', icon: 'security', label: '账号与安全' },
|
||||
{ key: 'notifications', to: '/settings/notifications', icon: 'notifications', label: '消息通知' },
|
||||
{ key: 'privacy', to: '/settings/privacy', icon: 'lock', label: '隐私' },
|
||||
{ key: 'general', to: '/settings/general', icon: 'tune', label: '通用' },
|
||||
{ key: 'about', to: '/settings/about', icon: 'info', label: '关于 MeetingIM' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §8.7 (mobile "我的" page): 我的收藏/文件助手/通话记录 are grouped ABOVE the regular
|
||||
* settings directory. Desktop never needs this list -- those three already have their own
|
||||
* permanent NavRail icons there -- but on a phone-width viewport (bottom tab bar instead of a rail)
|
||||
* there is otherwise NO way to reach `/favorites` or `/calls` at all short of typing the URL
|
||||
* directly (confirmed live: BottomTabBar.tsx only has 消息/通讯录/我的, unchanged since Phase 1-2
|
||||
* when those two routes didn't exist yet) -- a real navigation gap this Phase 9 pass caught, not a
|
||||
* cosmetic one. `/files` reuses the same `slf`-conversation redirect the desktop entry point does.
|
||||
*/
|
||||
const MOBILE_SHORTCUTS = [
|
||||
{ key: 'favorites', to: '/favorites', icon: 'inventory_2', label: '我的收藏', colorClass: 'text-accent' },
|
||||
{ key: 'files', to: '/files', icon: 'folder', label: '文件助手', colorClass: 'text-success' },
|
||||
{ key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). Logout is entirely
|
||||
* client-side -- confirmed live (docs/IM-后端问题清单.md 问题22) all three plausible logout
|
||||
* endpoints 404, matching the old app's own real implementation (tinode-web.jsx:1995-2038), which
|
||||
* never calls a server logout endpoint at all, just clears local token/session and disconnects.
|
||||
*/
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
export function SettingsLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
// README §8.7 draws TWO separate mobile screens (`#1d` 我的 hub, `#1e` 设置 sub-list) that don't
|
||||
// map 1:1 onto the desktop directory -- this is which of the two the bare `/settings` mobile route
|
||||
// currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar).
|
||||
const [mobileSubView, setMobileSubView] = useState(false);
|
||||
|
||||
const logout = () => {
|
||||
if (!window.confirm('确定退出登录吗?')) return;
|
||||
setLoggingOut(true);
|
||||
resetTinode();
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
// Deliberately NOT clearTenant() -- that clears useSessionStore's `tenant`, and LoginPage's own
|
||||
// `if (!tenant) return <Navigate to="/tenant" replace />` immediately bounces the navigate('/login')
|
||||
// below straight back to the tenant-code entry screen instead of this tenant's actual login page
|
||||
// (confirmed live: this used to be a real dead end, not a hypothetical). A plain logout should
|
||||
// stay on the same enterprise's login screen, ready for the same or a different user to sign back
|
||||
// in -- only the explicit "切换企业" link is supposed to clear the tenant.
|
||||
//
|
||||
// The QueryClient instance is module-scoped for the whole SPA lifetime (App.tsx) and survives a
|
||||
// client-side route change -- without this, a second account logging in on the same tab/device
|
||||
// (a shared kiosk machine, realistic for an enterprise client) would briefly render the FIRST
|
||||
// account's still-cached profile/privacy/blocks data before the background refetch resolves.
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
// Below the desktop breakpoint there's no room to dock both columns (confirmed live: without
|
||||
// this, the 260px directory and the content column both try to render side by side on a phone-
|
||||
// width viewport, squeezing the content into a sliver narrow enough that its own text wraps
|
||||
// one character per line). Mirrors ContactsLayout's exact `showListOnNarrow` pattern: show the
|
||||
// directory only at the bare `/settings` index, content only once a specific section is picked.
|
||||
const showListOnNarrow = location.pathname === '/settings';
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<aside className={`h-full w-full flex-none flex-col bg-white md:flex md:w-[260px] md:border-r md:border-border-panel ${showListOnNarrow ? 'flex' : 'hidden'}`}>
|
||||
{/* Desktop (README §8.1): one directory, matches ≥md exactly as before. */}
|
||||
<div className="hidden h-full flex-1 flex-col md:flex">
|
||||
<header className="flex h-15 flex-none items-center px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">设置</h1>
|
||||
</header>
|
||||
<div className="flex items-center gap-3 border-b border-border-list-item px-5 pb-4.5 pt-2">
|
||||
<Avatar
|
||||
id={meQuery.data?.user_id || ''}
|
||||
name={meQuery.data?.profile.nickname}
|
||||
avatarFileId={meQuery.data?.profile.avatar_file_id}
|
||||
className="h-12 w-12 flex-none rounded-xl text-base"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<strong className="truncate text-[15px] font-semibold text-text-body">{meQuery.data?.profile.nickname}</strong>
|
||||
<span className="truncate text-xs text-text-tertiary-2">ID:{meQuery.data?.profile.public_account}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-3">
|
||||
{DIRECTORY.map((item) => {
|
||||
const active = location.pathname.startsWith(item.to);
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm no-underline ${
|
||||
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={item.icon} className="text-[18px]" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
disabled={loggingOut}
|
||||
className="mx-3 my-4 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile hub (README §8.7, mock `07/1d`) -- a "me" landing page, not a settings directory:
|
||||
tappable profile card (-> /settings/profile), 我的收藏/文件助手/通话记录 (this app's only
|
||||
way to reach those three on a narrow viewport, see MOBILE_SHORTCUTS' own comment), then
|
||||
账号与安全/隐私 direct, and "设置" as its own second-level list rather than a 4th direct
|
||||
link -- these two screens genuinely don't share one flat structure in the mock. */}
|
||||
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'hidden' : 'flex'}`}>
|
||||
<Link to="/settings/profile" className="flex items-center gap-3.5 bg-white px-5 py-5 no-underline">
|
||||
<Avatar
|
||||
id={meQuery.data?.user_id || ''}
|
||||
name={meQuery.data?.profile.nickname}
|
||||
avatarFileId={meQuery.data?.profile.avatar_file_id}
|
||||
className="h-16 w-16 flex-none rounded-2xl text-2xl"
|
||||
/>
|
||||
<strong className="flex-1 truncate text-[19px] font-semibold text-text-heading">{meQuery.data?.profile.nickname}</strong>
|
||||
<Icon name="qr_code_2" className="flex-none text-xl text-text-tertiary-3" />
|
||||
<Icon name="chevron_right" className="flex-none text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<div className="flex flex-col gap-2.5 py-2.5">
|
||||
<div className="flex flex-col bg-white">
|
||||
{MOBILE_SHORTCUTS.map((item, i) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-3 px-5 py-3.5 text-base text-text-body no-underline ${i < MOBILE_SHORTCUTS.length - 1 ? 'border-b border-border-list-item' : ''}`}
|
||||
>
|
||||
<Icon name={item.icon} className={`text-xl ${item.colorClass}`} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col bg-white">
|
||||
<Link to="/settings/security" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<Icon name="security" className="text-xl text-[#7c5cf6]" />
|
||||
<span className="flex-1">账号与安全</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<Link to="/settings/privacy" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<Icon name="lock" className="text-xl text-[#e2537a]" />
|
||||
<span className="flex-1">隐私</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<button type="button" onClick={() => setMobileSubView(true)} className="flex items-center gap-3 px-5 py-3.5 text-left text-base text-text-body">
|
||||
<Icon name="settings" className="text-xl text-text-tertiary-3" />
|
||||
<span className="flex-1">设置</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile "设置" sub-list (mock `07/1e`) -- a second, DIFFERENT grouping from both the hub
|
||||
above and the desktop directory: 清空缓存/意见反馈/关于 sit here directly, not nested
|
||||
under 通用. 清空缓存 links through to 通用 rather than duplicating that action (and its
|
||||
already-established "no fake byte count" honesty) in a second place; 意见反馈 has no
|
||||
generic post-login feedback endpoint anywhere in this protocol (the only feedback API,
|
||||
`/tenant-entry-feedback`, is unauthenticated and pre-login-only per its own name) so this
|
||||
is a real, recorded backend gap, not a missed wire-up. 关于 omits the mock's red-dot
|
||||
"有新版本" -- already confirmed elsewhere that state can never be real (the update-check
|
||||
endpoint always rejects `platform=web`), showing it would be lying about system state. */}
|
||||
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'flex' : 'hidden'}`}>
|
||||
<div className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center bg-white px-1">
|
||||
<button type="button" onClick={() => setMobileSubView(false)} className="grid h-9 w-9 place-items-center text-text-heading">
|
||||
<Icon name="arrow_back_ios_new" className="text-[20px]" />
|
||||
</button>
|
||||
<strong className="text-center text-[17px] font-semibold text-text-heading">设置</strong>
|
||||
<span />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 py-2.5">
|
||||
<div className="flex flex-col bg-white">
|
||||
{DIRECTORY.filter((item) => item.key !== 'profile' && item.key !== 'about').map((item, i, arr) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline ${i < arr.length - 1 ? 'border-b border-border-list-item' : ''}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col bg-white">
|
||||
<Link to="/settings/general" className="flex items-center justify-between border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<span>清空缓存</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<button type="button" onClick={notImplemented} className="flex items-center justify-between border-b border-border-list-item px-5 py-3.5 text-left text-base text-text-body">
|
||||
<span>意见反馈</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</button>
|
||||
<Link to="/settings/about" className="flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<span>关于 MeetingIM</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
</div>
|
||||
<button type="button" onClick={logout} disabled={loggingOut} className="bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className={`h-full min-w-0 flex-1 flex-col overflow-y-auto bg-[#f6f7f9] md:flex ${showListOnNarrow ? 'hidden' : 'flex'}`}>
|
||||
<Link to="/settings" className="flex h-11 flex-none items-center gap-1 px-4 text-sm text-text-secondary no-underline md:hidden">
|
||||
<Icon name="arrow_back" className="text-[18px]" />
|
||||
设置
|
||||
</Link>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user