refactor: 封装请求

This commit is contained in:
2026-05-09 09:25:13 +08:00
parent 765b84e2b4
commit 7bed43ac96
19 changed files with 195 additions and 108 deletions

9
src/api/health.ts Normal file
View File

@@ -0,0 +1,9 @@
import { lotteryRequest } from "@/lib/lottery-http";
import type { HealthData } from "@/types/api/health";
import { API_V1_PREFIX } from "@/api/paths";
/** `GET /api/v1/health` */
export function getHealth(): Promise<HealthData> {
return lotteryRequest.get<HealthData>(`${API_V1_PREFIX}/health`);
}

4
src/api/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { API_V1_PREFIX } from "@/api/paths";
export { getHealth } from "@/api/health";
export { getPlayerPing, getPlayerMe } from "@/api/player";
export { getWalletBalance, type GetWalletBalanceParams } from "@/api/wallet";

2
src/api/paths.ts Normal file
View File

@@ -0,0 +1,2 @@
/** Laravel `routes/api.php``api` 前缀 + `v1` 分组 */
export const API_V1_PREFIX = "/api/v1";

15
src/api/player.ts Normal file
View File

@@ -0,0 +1,15 @@
import { lotteryRequest } from "@/lib/lottery-http";
import type { PlayerMeData } from "@/types/api/player-me";
import type { ScopePingData } from "@/types/api/ping";
import { API_V1_PREFIX } from "@/api/paths";
/** `GET /api/v1/player/ping`(无需登录) */
export function getPlayerPing(): Promise<ScopePingData> {
return lotteryRequest.get<ScopePingData>(`${API_V1_PREFIX}/player/ping`);
}
/** `GET /api/v1/player/me`(需玩家 Token */
export function getPlayerMe(): Promise<PlayerMeData> {
return lotteryRequest.get<PlayerMeData>(`${API_V1_PREFIX}/player/me`);
}

19
src/api/wallet.ts Normal file
View File

@@ -0,0 +1,19 @@
import { lotteryRequest } from "@/lib/lottery-http";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
import { API_V1_PREFIX } from "@/api/paths";
export type GetWalletBalanceParams = {
/** Query `currency`,不传则用玩家默认币种 */
currency?: string;
};
/** `GET /api/v1/wallet/balance`(需玩家 Token */
export function getWalletBalance(
params?: GetWalletBalanceParams,
): Promise<WalletBalanceData> {
return lotteryRequest.get<WalletBalanceData>(
`${API_V1_PREFIX}/wallet/balance`,
{ params },
);
}

View File

@@ -0,0 +1,9 @@
import { PlayerAppShell } from "@/components/layout/player-app-shell";
export default function PlayerLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return <PlayerAppShell>{children}</PlayerAppShell>;
}

23
src/app/(player)/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function PlayerHomePage() {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
layout
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
PlayerAppShell
</CardContent>
</Card>
);
}

View File

@@ -15,8 +15,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Lottery",
description: "Lottery player",
};
export default function RootLayout({

View File

@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
type PlayerAppShellProps = {
children: ReactNode;
};
/**
* 玩家端业务区外壳:顶栏 + 主内容(移动端宽度上限,桌面居中)。
* 标题 / 底部导航等后续再接 i18n、路由。
*/
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
return (
<div className="flex min-h-full flex-col bg-background text-foreground">
<header className="sticky top-0 z-40 shrink-0 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
<div className="mx-auto flex h-12 max-w-lg items-center px-4">
<span className="text-sm font-semibold tracking-tight">Lottery</span>
</div>
</header>
<main className="mx-auto flex w-full max-w-lg flex-1 flex-col gap-4 px-4 py-4">
{children}
</main>
</div>
);
}

View File

@@ -5,33 +5,11 @@ import axios, {
type AxiosResponse,
} from "axios";
/** 后端 `{ code, msg, data }`;约定 `code === 0` 表示成功 */
export type ApiEnvelope<T = unknown> = {
code: number;
msg: string;
data: T;
};
/** HTTP 状态非 2xx或 Axios 报错)但其 body 仍是业务信封且 `code !== 0` 时抛出 */
export class LotteryApiBizError extends Error {
readonly code: number;
readonly data: unknown;
constructor(message: string, code: number, data: unknown) {
super(message);
this.name = "LotteryApiBizError";
this.code = code;
this.data = data;
}
}
/** body 不是约定的信封结构 */
export class LotteryApiEnvelopeError extends Error {
constructor(message = "响应不是约定的 { code, msg, data }") {
super(message);
this.name = "LotteryApiEnvelopeError";
}
}
import {
LotteryApiBizError,
LotteryApiEnvelopeError,
} from "@/types/api/errors";
import { isApiEnvelope } from "@/types/api/envelope";
export function setLotteryRequestLocale(locale: string | null): void {
if (locale === null) {
@@ -69,18 +47,6 @@ function acceptLanguage(loc: ReturnType<typeof requestLocale>): string {
return "en-US,en;q=0.9";
}
function isEnvelope(v: unknown): v is ApiEnvelope {
if (v === null || typeof v !== "object") {
return false;
}
const o = v as Record<string, unknown>;
return (
typeof o.code === "number" &&
typeof o.msg === "string" &&
"data" in o
);
}
const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
/**
@@ -112,7 +78,7 @@ function mergeLocaleHeaders(
* `code !== 0` 抛 {@link LotteryApiBizError}。
*/
export function unwrapData<T>(payload: unknown): T {
if (!isEnvelope(payload)) {
if (!isApiEnvelope(payload)) {
throw new LotteryApiEnvelopeError();
}
if (payload.code !== 0) {
@@ -138,7 +104,7 @@ export async function request<T>(config: AxiosRequestConfig): Promise<T> {
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.data !== undefined) {
const body = err.response.data;
if (isEnvelope(body) && body.code !== 0) {
if (isApiEnvelope(body) && body.code !== 0) {
throw new LotteryApiBizError(body.msg, body.code, body.data);
}
}

18
src/types/api/envelope.ts Normal file
View File

@@ -0,0 +1,18 @@
/** 后端 `{ code, msg, data }`;约定 `code === 0` 表示成功 */
export type ApiEnvelope<T = unknown> = {
code: number;
msg: string;
data: T;
};
export function isApiEnvelope(v: unknown): v is ApiEnvelope {
if (v === null || typeof v !== "object") {
return false;
}
const o = v as Record<string, unknown>;
return (
typeof o.code === "number" &&
typeof o.msg === "string" &&
"data" in o
);
}

20
src/types/api/errors.ts Normal file
View File

@@ -0,0 +1,20 @@
/** HTTP 非成功或其 body 为业务信封且 `code !== 0` 时抛出 */
export class LotteryApiBizError extends Error {
readonly code: number;
readonly data: unknown;
constructor(message: string, code: number, data: unknown) {
super(message);
this.name = "LotteryApiBizError";
this.code = code;
this.data = data;
}
}
/** body 不是约定的信封结构 */
export class LotteryApiEnvelopeError extends Error {
constructor(message = "响应不是约定的 { code, msg, data }") {
super(message);
this.name = "LotteryApiEnvelopeError";
}
}

7
src/types/api/health.ts Normal file
View File

@@ -0,0 +1,7 @@
/** `GET /api/v1/health` → `data` */
export type HealthData = {
app: string;
default_currency: string;
/** 仅 `APP_DEBUG=true` 时出现 */
laravel?: string;
};

11
src/types/api/index.ts Normal file
View File

@@ -0,0 +1,11 @@
export type { ApiEnvelope } from "./envelope";
export { isApiEnvelope } from "./envelope";
export {
LotteryApiBizError,
LotteryApiEnvelopeError,
} from "./errors";
export type { HealthData } from "./health";
export type { ScopePingData } from "./ping";
export type { PlayerMeData } from "./player-me";
export type { WalletBalanceData } from "./wallet-balance";

4
src/types/api/ping.ts Normal file
View File

@@ -0,0 +1,4 @@
/** `GET /api/v1/player/ping` → `data` */
export type ScopePingData = {
scope: string;
};

View File

@@ -0,0 +1,10 @@
/** `GET /api/v1/player/me` → `data`(需 `lottery.player` */
export type PlayerMeData = {
id: number;
site_code: string;
site_player_id: string;
username: string;
nickname: string | null;
default_currency: string;
status: number;
};

View File

@@ -0,0 +1,9 @@
/** `GET /api/v1/wallet/balance` → `data`(需 `lottery.player` */
export type WalletBalanceData = {
/** 最小货币单位 bigint序列化可能为 string */
balance: string | number;
main_balance: null;
currency_code: string;
wallet_type: string;
frozen_balance: string | number;
};

2
src/types/index.ts Normal file
View File

@@ -0,0 +1,2 @@
/** 对外类型出口API 契约与接口 DTO 见 `./api` */
export * from "./api";