Files
carrotskin/src/lib/api.ts
Wu XiangYu 268344c357
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 15m39s
fix: 修复前端多项问题并完善认证流程
- 修复 AuthContext 硬编码 localhost:8080,改用 api.ts 统一的 API_BASE_URL(走环境变量/代理,生产可部署)
- 修复注册页发送验证码绕过 api.ts 直接 raw fetch 的问题,改用统一的 sendVerificationCode
- 修复 SliderCaptcha 验证成功后 captchaId 未透传导致注册人机验证失效的断链
- 修复头像上传契约:预签名URL方式(/user/avatar/upload-url)改为直传(/user/avatar/upload)
- 移除后端不支持的'设置活跃角色'功能(/profile/:uuid/activate)及相关死代码
- 修复分页字段不一致:新增 normalizePaginatedData 兼容后端 per_page 响应
- 强化邮箱正则校验(RFC 标准,禁止连续点/首尾点,TLD>=2字母)
- 移除首页指向无效 /api 页面的'查看API文档'按钮
- 新增 /register /login /signup 重定向路由,统一指向 /auth?mode=
- 同步 API文档.md
2026-07-08 17:36:09 +08:00

437 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || '/api/v1';
export interface Texture {
id: number;
uploader_id: number;
name: string;
description?: string;
type: 'SKIN' | 'CAPE';
url: string;
hash: string;
size: number;
is_public: boolean;
download_count: number;
favorite_count: number;
is_slim: boolean;
status: number;
created_at: string;
updated_at: string;
}
export interface Profile {
uuid: string;
user_id: number;
name: string;
skin_id?: number;
cape_id?: number;
is_active?: boolean;
last_used_at?: string;
created_at: string;
updated_at: string;
}
export interface PaginatedResponse<T> {
list: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
// 后端原始分页响应结构(统一返回 { list, total, page, per_page },不含 total_pages/page_size
type RawPaginatedData<T> = {
list?: T[];
total?: number;
page?: number;
per_page?: number;
page_size?: number;
};
// 在前端补齐 page_size 与 total_pages由 total / page_size 向上取整),以便 UI 分页使用
function normalizePaginatedData<T>(raw: RawPaginatedData<T>): PaginatedResponse<T> {
const list = raw.list ?? [];
const total = raw.total ?? 0;
const page = raw.page ?? 1;
const page_size = raw.per_page ?? raw.page_size ?? 20;
const total_pages = page_size > 0 ? Math.max(1, Math.ceil(total / page_size)) : 1;
return { list, total, page, page_size, total_pages };
}
// 后端错误响应的 data 可能不是分页结构code !== 200用此类型宽松接收
type RawApiResponse<T> = {
code: number;
message: string;
data: T;
};
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
// 获取认证头
function getAuthHeaders(): HeadersInit {
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// 搜索材质
export async function searchTextures(params: {
keyword?: string;
type?: 'SKIN' | 'CAPE';
public_only?: boolean;
page?: number;
page_size?: number;
}): Promise<ApiResponse<PaginatedResponse<Texture>>> {
const queryParams = new URLSearchParams();
if (params.keyword) queryParams.append('keyword', params.keyword);
if (params.type) queryParams.append('type', params.type);
if (params.public_only !== undefined) queryParams.append('public_only', String(params.public_only));
if (params.page) queryParams.append('page', String(params.page));
if (params.page_size) queryParams.append('page_size', String(params.page_size));
const response = await fetch(`${API_BASE_URL}/texture?${queryParams.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const result: RawApiResponse<RawPaginatedData<Texture>> = await response.json();
if (result.code === 200 && result.data) {
return { ...result, data: normalizePaginatedData(result.data) };
}
return result as ApiResponse<PaginatedResponse<Texture>>;
}
// 获取材质详情
export async function getTexture(id: number): Promise<ApiResponse<Texture>> {
const response = await fetch(`${API_BASE_URL}/texture/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return response.json();
}
// 切换收藏状态
export async function toggleFavorite(id: number): Promise<ApiResponse<{ is_favorited: boolean }>> {
const response = await fetch(`${API_BASE_URL}/texture/${id}/favorite`, {
method: 'POST',
headers: getAuthHeaders(),
});
return response.json();
}
// 获取用户上传的材质列表
export async function getMyTextures(params: {
page?: number;
page_size?: number;
}): Promise<ApiResponse<PaginatedResponse<Texture>>> {
const queryParams = new URLSearchParams();
if (params.page) queryParams.append('page', String(params.page));
if (params.page_size) queryParams.append('page_size', String(params.page_size));
const response = await fetch(`${API_BASE_URL}/texture/my?${queryParams.toString()}`, {
method: 'GET',
headers: getAuthHeaders(),
});
const result: RawApiResponse<RawPaginatedData<Texture>> = await response.json();
if (result.code === 200 && result.data) {
return { ...result, data: normalizePaginatedData(result.data) };
}
return result as ApiResponse<PaginatedResponse<Texture>>;
}
// 获取用户收藏的材质列表
export async function getFavoriteTextures(params: {
page?: number;
page_size?: number;
}): Promise<ApiResponse<PaginatedResponse<Texture>>> {
const queryParams = new URLSearchParams();
if (params.page) queryParams.append('page', String(params.page));
if (params.page_size) queryParams.append('page_size', String(params.page_size));
const response = await fetch(`${API_BASE_URL}/texture/favorites?${queryParams.toString()}`, {
method: 'GET',
headers: getAuthHeaders(),
});
const result: RawApiResponse<RawPaginatedData<Texture>> = await response.json();
if (result.code === 200 && result.data) {
return { ...result, data: normalizePaginatedData(result.data) };
}
return result as ApiResponse<PaginatedResponse<Texture>>;
}
// 获取用户档案列表
export async function getProfiles(): Promise<ApiResponse<Profile[]>> {
const response = await fetch(`${API_BASE_URL}/profile`, {
method: 'GET',
headers: getAuthHeaders(),
});
return response.json();
}
// 创建档案
export async function createProfile(name: string): Promise<ApiResponse<Profile>> {
const response = await fetch(`${API_BASE_URL}/profile`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name }),
});
return response.json();
}
// 更新档案
// skin_id / cape_id: 显式传 null 表示移除当前皮肤的关联;传 number 表示设置;不传表示不修改。
export async function updateProfile(uuid: string, data: {
name?: string;
skin_id?: number | null;
cape_id?: number | null;
}): Promise<ApiResponse<Profile>> {
const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
return response.json();
}
// 删除档案
export async function deleteProfile(uuid: string): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
return response.json();
}
// 获取用户信息
export async function getUserProfile(): Promise<ApiResponse<{
id: number;
username: string;
email: string;
avatar?: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}>> {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'GET',
headers: getAuthHeaders(),
});
return response.json();
}
// 更新用户信息
export async function updateUserProfile(data: {
avatar?: string;
old_password?: string;
new_password?: string;
}): Promise<ApiResponse<{
id: number;
username: string;
email: string;
avatar?: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}>> {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
return response.json();
}
// 直接上传皮肤文件
export async function uploadTexture(file: File, data: {
name: string;
description?: string;
type?: 'SKIN' | 'CAPE';
is_public?: boolean;
is_slim?: boolean;
}): Promise<ApiResponse<Texture>> {
const formData = new FormData();
formData.append('file', file);
formData.append('name', data.name);
if (data.description) formData.append('description', data.description);
if (data.type) formData.append('type', data.type);
if (data.is_public !== undefined) formData.append('is_public', String(data.is_public));
if (data.is_slim !== undefined) formData.append('is_slim', String(data.is_slim));
const response = await fetch(`${API_BASE_URL}/texture/upload`, {
method: 'POST',
headers: {
...(typeof window !== 'undefined' ? { Authorization: `Bearer ${localStorage.getItem('authToken')}` } : {}),
},
body: formData,
});
return response.json();
}
// 直接上传头像文件到后端multipart/form-data
// 后端接口 POST /user/avatar/upload由后端负责写入对象存储并更新用户头像
export async function uploadAvatar(file: File): Promise<ApiResponse<{
avatar_url: string;
user: {
id: number;
username: string;
email: string;
avatar: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
};
}>> {
const formData = new FormData();
formData.append('file', file);
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
const response = await fetch(`${API_BASE_URL}/user/avatar/upload`, {
method: 'POST',
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
body: formData,
});
return response.json();
}
// 更新头像URL用于外部URL场景区别于文件直传
export async function updateAvatarUrl(avatarUrl: string): Promise<ApiResponse<{
id: number;
username: string;
email: string;
avatar: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}>> {
const response = await fetch(`${API_BASE_URL}/user/avatar?avatar_url=${encodeURIComponent(avatarUrl)}`, {
method: 'PUT',
headers: getAuthHeaders(),
});
return response.json();
}
// 重置Yggdrasil密码
export async function resetYggdrasilPassword(): Promise<ApiResponse<{
password: string;
}>> {
const response = await fetch(`${API_BASE_URL}/user/yggdrasil-password/reset`, {
method: 'POST',
headers: getAuthHeaders(),
});
return response.json();
}
// 删除材质
export async function deleteTexture(id: number): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE_URL}/texture/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
return response.json();
}
// 更新材质信息(名称、描述、公开性)
// 对应后端 PUT /texture/{id}is_public 为布尔值时会更新该字段,会影响公开/隐藏状态
export async function updateTexture(id: number, data: {
name?: string;
description?: string;
is_public?: boolean;
}): Promise<ApiResponse<Texture>> {
const response = await fetch(`${API_BASE_URL}/texture/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
return response.json();
}
// 发送邮箱验证码
// type: register | reset_password | change_email
export async function sendVerificationCode(email: string, type: 'register' | 'reset_password' | 'change_email'): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE_URL}/auth/send-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, type }),
});
return response.json();
}
// 更换邮箱
export async function changeEmail(newEmail: string, verificationCode: string): Promise<ApiResponse<{
id: number;
username: string;
email: string;
avatar: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}>> {
const response = await fetch(`${API_BASE_URL}/user/change-email`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ new_email: newEmail, verification_code: verificationCode }),
});
return response.json();
}
// 重置密码(通过邮箱验证码)
// 对应后端 POST /auth/reset-password无需 JWT
export async function resetPassword(email: string, verificationCode: string, newPassword: string): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE_URL}/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
verification_code: verificationCode,
new_password: newPassword,
}),
});
return response.json();
}