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
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>小白复盘</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2170
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "xiaobai-review-next-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc -b && vite build",
"check": "vue-tsc -b --pretty false",
"test": "vitest run"
},
"dependencies": {
"pinia": "4.0.2",
"vue": "3.5.40",
"vue-router": "5.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.8",
"typescript": "5.9.3",
"vite": "8.1.5",
"vitest": "4.1.10",
"vue-tsc": "3.3.8"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+8
View File
@@ -0,0 +1,8 @@
import { createRouter, createWebHistory } from "vue-router";
import BootstrapView from "./views/BootstrapView.vue";
export default createRouter({
history: createWebHistory(),
routes: [{ path: "/", name: "bootstrap", component: BootstrapView }],
});
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../../shared/api/client";
type Health = {
status: string;
environment: string;
};
const health = ref<Health | null>(null);
const failure = ref("");
onMounted(async () => {
try {
health.value = await api.get<Health>("/health");
} catch (error) {
failure.value = error instanceof Error ? error.message : "服务连接失败";
}
});
</script>
<template>
<main class="bootstrap-view">
<section class="bootstrap-panel" aria-live="polite">
<h1>小白复盘</h1>
<p v-if="health">新系统基础服务已连接{{ health.environment }}</p>
<p v-else-if="failure" class="failure">{{ failure }}</p>
<p v-else>正在连接基础服务</p>
</section>
</main>
</template>
<style scoped>
.bootstrap-view {
min-height: 100vh;
display: grid;
place-items: center;
padding: var(--space-16);
}
.bootstrap-panel {
width: min(100%, 420px);
padding: var(--space-22);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-card);
}
h1 {
margin: 0 0 var(--space-8);
font-size: var(--font-page-title);
}
p {
margin: 0;
color: var(--text-secondary);
}
.failure {
color: var(--warning);
}
</style>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+9
View File
@@ -0,0 +1,9 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./app/App.vue";
import router from "./app/router";
import "./shared/styles/tokens.css";
import "./shared/styles/base.css";
createApp(App).use(createPinia()).use(router).mount("#app");
@@ -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);
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"jsx": "preserve"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
strictPort: true,
proxy: {
"/api": "http://127.0.0.1:8780",
},
},
});