refactor: 封装请求
This commit is contained in:
9
src/api/health.ts
Normal file
9
src/api/health.ts
Normal 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
4
src/api/index.ts
Normal 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
2
src/api/paths.ts
Normal 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
15
src/api/player.ts
Normal 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
19
src/api/wallet.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
9
src/app/(player)/layout.tsx
Normal file
9
src/app/(player)/layout.tsx
Normal 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
23
src/app/(player)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
src/components/layout/player-app-shell.tsx
Normal file
24
src/components/layout/player-app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
18
src/types/api/envelope.ts
Normal 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
20
src/types/api/errors.ts
Normal 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
7
src/types/api/health.ts
Normal 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
11
src/types/api/index.ts
Normal 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
4
src/types/api/ping.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** `GET /api/v1/player/ping` → `data` */
|
||||
export type ScopePingData = {
|
||||
scope: string;
|
||||
};
|
||||
10
src/types/api/player-me.ts
Normal file
10
src/types/api/player-me.ts
Normal 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;
|
||||
};
|
||||
9
src/types/api/wallet-balance.ts
Normal file
9
src/types/api/wallet-balance.ts
Normal 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
2
src/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** 对外类型出口;API 契约与接口 DTO 见 `./api` */
|
||||
export * from "./api";
|
||||
Reference in New Issue
Block a user