rebuild(stage-1): establish isolated application skeleton
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { api, ApiError } from "./client";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("api client", () => {
|
||||
it("uses the single API prefix", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: "ok" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(api.get<{ status: string }>("/health")).resolves.toEqual({ status: "ok" });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/health",
|
||||
expect.objectContaining({ credentials: "same-origin" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes safe server errors", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: { code: "unavailable", message: "暂不可用", correlation_id: "abc" },
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const promise = api.get("/health");
|
||||
await expect(promise).rejects.toBeInstanceOf(ApiError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: "暂不可用",
|
||||
status: 503,
|
||||
code: "unavailable",
|
||||
correlationId: "abc",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
type ApiErrorPayload = {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
correlation_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
readonly correlationId?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json", ...init?.headers },
|
||||
...init,
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
payload.error?.message ?? "请求失败,请稍后重试。",
|
||||
response.status,
|
||||
payload.error?.code ?? "request_failed",
|
||||
payload.error?.correlation_id,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get<T>(path: string): Promise<T> {
|
||||
return request<T>(path);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--text-primary);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
background: #f4f5f7;
|
||||
|
||||
--canvas: #f4f5f7;
|
||||
--surface: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--primary: #2563eb;
|
||||
--warning: #b45309;
|
||||
|
||||
--space-8: 8px;
|
||||
--space-16: 16px;
|
||||
--space-22: 22px;
|
||||
--radius-card: 10px;
|
||||
--font-page-title: 17px;
|
||||
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
color: #e8eaed;
|
||||
background: #121416;
|
||||
|
||||
--canvas: #121416;
|
||||
--surface: #1b1e21;
|
||||
--border: #343a40;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #adb5bd;
|
||||
--primary: #6ca8e8;
|
||||
--warning: #e2ad58;
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
Reference in New Issue
Block a user