rebuild(stage-1): establish isolated application skeleton

This commit is contained in:
leefer
2026-07-30 00:46:43 +08:00
parent 6df26a2545
commit 603e73d128
32 changed files with 2691 additions and 0 deletions
@@ -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",
});
});
});
+42
View File
@@ -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);
},
};
+24
View File
@@ -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);
}