feat(profile): avatar presets, upload, nickname editing

- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
  safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
  resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
  partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-06 13:51:43 +08:00
parent 59d4b19d7d
commit 7ad52fce89
27 changed files with 1271 additions and 270 deletions

View File

@@ -1,11 +1,13 @@
import { api } from '@/core/api/client';
import type {
AvatarPresetItem,
ChangePasswordRequest,
ChangePhoneRequest,
LoginRequest,
RegisterRequest,
ResetPasswordRequest,
SetAvatarPresetRequest,
SmsLoginRequest,
SmsRegisterRequest,
SmsRequest,
@@ -90,4 +92,14 @@ export const authApi = {
uploadAvatar(file: FormData) {
return api.post<UserInfo>(`${AUTH}/me/avatar`, { body: file });
},
fetchAvatarPresets() {
return api.get<AvatarPresetItem[]>(`${AUTH}/avatar-presets`, {
skipAuth: true,
});
},
setAvatarPreset(body: SetAvatarPresetRequest) {
return api.put<UserInfo>(`${AUTH}/me/avatar/preset`, { body });
},
} as const;

View File

@@ -0,0 +1,80 @@
import type * as ImagePicker from 'expo-image-picker';
import { Platform } from 'react-native';
type AvatarMime = 'image/jpeg' | 'image/png' | 'image/webp';
function inferMimeFromUri(uri: string): AvatarMime {
const u = uri.toLowerCase();
if (u.endsWith('.png')) return 'image/png';
if (u.endsWith('.webp')) return 'image/webp';
return 'image/jpeg';
}
function coerceMime(value: string | null | undefined, uri: string): AvatarMime {
if (
value === 'image/jpeg' ||
value === 'image/png' ||
value === 'image/webp'
) {
return value;
}
return inferMimeFromUri(uri);
}
function mimeToFilename(mime: AvatarMime): string {
switch (mime) {
case 'image/png':
return 'avatar.png';
case 'image/webp':
return 'avatar.webp';
default:
return 'avatar.jpg';
}
}
/**
* 构建与后端 `POST /api/auth/me/avatar` 约定的 multipart字段名 `file`)。
* Native`{ uri, name, type }`Web`File`,避免 RN FormData 在 Web 上不识别 `uri`。
*/
export async function buildAvatarUploadFormData(
asset: ImagePicker.ImagePickerAsset,
): Promise<FormData> {
const uri = asset.uri;
const mime = coerceMime(asset.mimeType, uri);
const filename = mimeToFilename(mime);
const form = new FormData();
if (Platform.OS === 'web') {
const webFile = asset.file;
if (
webFile instanceof File &&
(webFile.type === 'image/jpeg' ||
webFile.type === 'image/png' ||
webFile.type === 'image/webp')
) {
form.append(
'file',
webFile,
webFile.name || mimeToFilename(coerceMime(webFile.type, uri)),
);
return form;
}
const res = await fetch(uri);
const blob = await res.blob();
const type = coerceMime(blob.type, uri);
form.append('file', new File([blob], mimeToFilename(type), { type }));
return form;
}
form.append(
'file',
{
uri,
name: filename,
type: mime,
} as unknown as Blob,
);
return form;
}

View File

@@ -14,6 +14,7 @@ import type {
SmsRegisterRequest,
SmsRequest,
TokenResponse,
UpdateNicknameRequest,
UserInfo,
} from './types';
@@ -24,6 +25,16 @@ export const authKeys = {
tokenCheck: ['auth', 'token-check'] as const,
};
const PROFILE_QUERY_PREFIX = ['profile'] as const;
function syncSessionAndProfileQueries(
queryClient: ReturnType<typeof useQueryClient>,
user: UserInfo,
) {
queryClient.setQueryData(authKeys.session, user);
queryClient.invalidateQueries({ queryKey: PROFILE_QUERY_PREFIX });
}
// ─── useSession ───
/**
@@ -162,6 +173,45 @@ export function useSmsCode() {
});
}
// ─── Avatar / nickname ───
export function useAvatarPresets() {
return useQuery({
queryKey: ['avatar-presets'],
queryFn: () => authApi.fetchAvatarPresets(),
staleTime: 60 * 60 * 1000,
});
}
export function useUpdateNickname() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: UpdateNicknameRequest) =>
authApi.updateNickname(body),
onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user),
});
}
export function useUploadAvatar() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (form: FormData) => authApi.uploadAvatar(form),
onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user),
});
}
export function useSetAvatarPreset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (presetId: string) =>
authApi.setAvatarPreset({ preset_id: presetId }),
onSuccess: (user) => syncSessionAndProfileQueries(queryClient, user),
});
}
// ─── useLogout ───
/**

View File

@@ -79,6 +79,15 @@ export interface UpdateNicknameRequest {
nickname: string;
}
export interface AvatarPresetItem {
id: string;
url: string;
}
export interface SetAvatarPresetRequest {
preset_id: string;
}
// ─── Session state ───
export type SessionStatus =

View File

@@ -15,6 +15,10 @@ interface UsePlayerResult {
enqueue: (item: PlaybackItem) => void;
/** Replace queue and play this item (e.g. user voice bubble vs other sources). */
enqueueExclusive: (item: PlaybackItem) => Promise<void>;
/** Pause native playback without draining queue与 stop 清空队列不同)。 */
pausePlayback: () => void;
/** Continue after pausePlayback需 status === 'paused' */
resumePlayback: () => void;
stop: () => void;
}
@@ -68,9 +72,11 @@ export function usePlayer(): UsePlayerResult {
useEffect(() => {
if (!currentSource || !player) return;
if (!playerStatus.isLoaded) return;
/** 先于 isLoaded「抢暂停」时需保留暂停避免本条自动 play 覆盖 pause */
if (status === 'paused') return;
player.play();
isPlayingRef.current = true;
}, [currentSource, player, playerStatus.isLoaded]);
}, [currentSource, player, playerStatus.isLoaded, status]);
const playNext = useCallback(async () => {
if (isPlayNextInProgressRef.current) return;
@@ -114,6 +120,7 @@ export function usePlayer(): UsePlayerResult {
// Detect playback completion → advance queue必须曾 playing避免换源瞬间沿用上一条的 duration/currentTime
useEffect(() => {
if (status === 'paused') return;
if (!currentSource || !isPlayingRef.current) return;
const { playing, currentTime, duration } = playerStatus;
@@ -128,7 +135,32 @@ export function usePlayer(): UsePlayerResult {
isPlayingRef.current = false;
playNext();
}
}, [playerStatus, currentSource, playNext]);
}, [playerStatus, currentSource, playNext, status]);
const pausePlayback = useCallback(() => {
setStatus((s) => {
if (s !== 'playing') return s;
if (player) {
player.pause();
}
isPlayingRef.current = false;
return 'paused';
});
}, [player]);
const resumePlayback = useCallback(async () => {
if (status !== 'paused') return;
const acquired = await audioFocus.acquireForPlayback();
if (!acquired) {
setStatus('idle');
return;
}
if (!player) return;
if (!playerStatus.isLoaded) return;
player.play();
setStatus('playing');
isPlayingRef.current = true;
}, [status, player, playerStatus.isLoaded]);
// Subscribe to audioFocus owner changes for recorder → idle recovery
useEffect(() => {
@@ -205,6 +237,8 @@ export function usePlayer(): UsePlayerResult {
currentPlaybackItem,
enqueue,
enqueueExclusive,
pausePlayback,
resumePlayback,
stop,
};
}