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:
@@ -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;
|
||||
|
||||
80
app-expo/src/features/auth/avatar-upload-form-data.ts
Normal file
80
app-expo/src/features/auth/avatar-upload-form-data.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ───
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user