From c31e834a67a657dbbb8b78320c0a65674d30bb68 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 02:28:54 +0800 Subject: [PATCH 01/11] feat(shared-api): add common API client Frontend entity APIs need a shared transport boundary aligned with backend response contracts. Add environment-based URL resolution, Bearer token injection, envelope decoding, pagination mapping, and normalized errors. Future entity implementations can reuse one business-agnostic client without page-level configuration. --- frontend/src/shared/api/index.ts | 210 +++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 frontend/src/shared/api/index.ts diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts new file mode 100644 index 0000000..41675fc --- /dev/null +++ b/frontend/src/shared/api/index.ts @@ -0,0 +1,210 @@ +export type ApiQueryValue = string | number | boolean | null | undefined + +export type ApiRequestOptions = Omit & { + body?: BodyInit | null + json?: unknown + query?: Record +} + +/** 后端 ListResponse 解包后的传输结果。 */ +export interface ApiListResult { + items: T[] + total: number + page: number + pageSize: number +} + +export interface ApiClient { + request(path: string, options?: ApiRequestOptions): Promise + requestList(path: string, options?: ApiRequestOptions): Promise> +} + +export interface ApiClientOptions { + /** 默认读取 VITE_API_BASE_URL;测试或独立环境可显式覆盖。 */ + baseUrl?: string + fetchFn?: typeof fetch + /** 只在请求发出时读取;token 的取得、保存与刷新由调用方负责。 */ + getAccessToken?: () => string | null | undefined +} + +export type ApiErrorKind = 'business' | 'http' | 'invalid-response' | 'network' + +/** 后端业务错误与传输错误统一进入这一种前端错误。 */ +export class ApiError extends Error { + readonly kind: ApiErrorKind + readonly code: number | null + readonly status: number | null + readonly data: unknown + + constructor( + message: string, + options: { + kind: ApiErrorKind + code?: number | null + status?: number | null + data?: unknown + cause?: unknown + }, + ) { + super(message, { cause: options.cause }) + this.name = 'ApiError' + this.kind = options.kind + this.code = options.code ?? null + this.status = options.status ?? null + this.data = options.data ?? null + } +} + +interface ApiEnvelope { + code: number + message: string + data: unknown + [key: string]: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function readEnvelope(response: Response): Promise { + let value: unknown + try { + value = await response.json() + } catch (cause) { + throw new ApiError(response.ok ? '后端响应格式无效' : 'HTTP 请求失败', { + kind: response.ok ? 'invalid-response' : 'http', + status: response.status, + cause, + }) + } + + if ( + !isRecord(value) || + typeof value.code !== 'number' || + typeof value.message !== 'string' || + !Object.hasOwn(value, 'data') + ) { + throw new ApiError(response.ok ? '后端响应格式无效' : 'HTTP 请求失败', { + kind: response.ok ? 'invalid-response' : 'http', + status: response.status, + data: value, + }) + } + + return value as ApiEnvelope +} + +/** 后端业务异常仍返回 HTTP 200,因此不能只依赖 Response.ok。 */ +function assertSuccessfulEnvelope(response: Response, envelope: ApiEnvelope): void { + if (envelope.code !== 200) { + throw new ApiError(envelope.message, { + kind: 'business', + code: envelope.code, + status: response.status, + data: envelope.data, + }) + } + + if (!response.ok) { + throw new ApiError('HTTP 请求失败', { + kind: 'http', + status: response.status, + data: envelope.data, + }) + } +} + +function buildUrl( + baseUrl: string, + path: string, + query: Record | undefined, +): string { + const url = `${baseUrl}/${path.replace(/^\/+/, '')}` + if (!query) return url + + const search = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined) search.set(key, String(value)) + } + + const serialized = search.toString() + if (!serialized) return url + return `${url}${url.includes('?') ? '&' : '?'}${serialized}` +} + +function buildRequestInit( + options: ApiRequestOptions | undefined, + accessToken: string | null | undefined, +): RequestInit | undefined { + if (!options && !accessToken) return undefined + + const { json, query: _query, headers: inputHeaders, ...init } = options ?? {} + const headers = new Headers(inputHeaders) + if (accessToken && !headers.has('authorization')) { + headers.set('authorization', `Bearer ${accessToken}`) + } + if (json === undefined) return { ...init, headers } + + if (!headers.has('content-type')) headers.set('content-type', 'application/json') + return { ...init, headers, body: JSON.stringify(json) } +} + +export function resolveApiBaseUrl(baseUrl = import.meta.env.VITE_API_BASE_URL): string { + const normalized = baseUrl?.trim().replace(/\/+$/, '') + if (!normalized) throw new Error('VITE_API_BASE_URL 未配置') + return normalized +} + +/** 创建一个只负责 HTTP 传输与公共响应解包的客户端。 */ +export function createApiClient({ + baseUrl, + fetchFn = globalThis.fetch, + getAccessToken, +}: ApiClientOptions): ApiClient { + const normalizedBaseUrl = resolveApiBaseUrl(baseUrl) + + async function send(path: string, options: ApiRequestOptions | undefined): Promise { + const url = buildUrl(normalizedBaseUrl, path, options?.query) + const init = buildRequestInit(options, getAccessToken?.()) + try { + return await fetchFn(url, init) + } catch (cause) { + throw new ApiError('网络请求失败', { kind: 'network', cause }) + } + } + + return { + async request(path: string, options?: ApiRequestOptions) { + const response = await send(path, options) + const envelope = await readEnvelope(response) + assertSuccessfulEnvelope(response, envelope) + + return envelope.data as T + }, + async requestList(path: string, options?: ApiRequestOptions) { + const response = await send(path, options) + const envelope = await readEnvelope(response) + assertSuccessfulEnvelope(response, envelope) + + if ( + !Array.isArray(envelope.data) || + !Number.isInteger(envelope.total) || + !Number.isInteger(envelope.page) || + !Number.isInteger(envelope.page_size) + ) { + throw new ApiError('后端列表响应格式无效', { + kind: 'invalid-response', + status: response.status, + data: envelope, + }) + } + + return { + items: envelope.data as T[], + total: envelope.total as number, + page: envelope.page as number, + pageSize: envelope.page_size as number, + } + }, + } +} From b31947cced7fed0d92abc9b4144e9afeb216641f Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 02:29:15 +0800 Subject: [PATCH 02/11] test(shared-api): cover backend transport contract The shared client needs executable evidence for the response and authentication rules declared by the backend. Cover success, business errors, pagination, request serialization, Bearer headers, invalid envelopes, HTTP failures, and network failures. Contract regressions now fail before entity API implementations depend on the transport layer. --- frontend/src/shared/api/index.test.ts | 226 ++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 frontend/src/shared/api/index.test.ts diff --git a/frontend/src/shared/api/index.test.ts b/frontend/src/shared/api/index.test.ts new file mode 100644 index 0000000..6d73e3f --- /dev/null +++ b/frontend/src/shared/api/index.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ApiError, createApiClient } from './index' + +afterEach(() => vi.unstubAllEnvs()) + +describe('createApiClient', () => { + it('returns data from a successful backend response envelope', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: { id: 7 }, + }), + { headers: { 'content-type': 'application/json' } }, + ), + }) + + await expect(client.request<{ id: number }>('/resources/7')).resolves.toEqual({ id: 7 }) + }) + + it('rejects a backend business error even when HTTP status is 200', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 400, + message: '请求参数错误', + data: { field: 'name' }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + }) + + const error = await client.request('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'business', + code: 400, + status: 200, + message: '请求参数错误', + data: { field: 'name' }, + }) + }) + + it('maps a successful list response to a camel-case list result', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [{ id: 7 }, { id: 8 }], + total: 42, + page: 2, + page_size: 20, + }), + { headers: { 'content-type': 'application/json' } }, + ), + }) + + await expect(client.requestList<{ id: number }>('/resources')).resolves.toEqual({ + items: [{ id: 7 }, { id: 8 }], + total: 42, + page: 2, + pageSize: 20, + }) + }) + + it('serializes query values and a JSON request body', async () => { + let capturedRequest: Request | undefined + const client = createApiClient({ + baseUrl: 'https://api.windup.test/', + fetchFn: async (input, init) => { + capturedRequest = new Request(input, init) + return new Response(JSON.stringify({ code: 200, message: 'success', data: null })) + }, + }) + + await client.request('/resources', { + method: 'POST', + query: { page: 2, page_size: 20, user_id: null }, + json: { name: 'sample' }, + }) + + if (!capturedRequest) throw new Error('request was not sent') + expect(capturedRequest.url).toBe('https://api.windup.test/resources?page=2&page_size=20') + expect(capturedRequest.headers.get('content-type')).toBe('application/json') + await expect(capturedRequest.json()).resolves.toEqual({ name: 'sample' }) + }) + + it('adds the current access token as a Bearer authorization header', async () => { + let authorization: string | null = null + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + getAccessToken: () => 'access-token', + fetchFn: async (input, init) => { + authorization = new Request(input, init).headers.get('authorization') + return new Response(JSON.stringify({ code: 200, message: 'success', data: null })) + }, + }) + + await client.request('/auth/me') + + expect(authorization).toBe('Bearer access-token') + }) + + it('wraps a rejected fetch as a network ApiError', async () => { + const connectionError = new TypeError('Failed to fetch') + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => Promise.reject(connectionError), + }) + + const error = await client.request('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'network', + code: null, + status: null, + message: '网络请求失败', + cause: connectionError, + }) + }) + + it('rejects a successful HTTP response that does not match the backend envelope', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ data: { id: 7 } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + }) + + const error = await client.request('/resources/7').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'invalid-response', + code: null, + status: 200, + message: '后端响应格式无效', + }) + }) + + it('rejects a list response with invalid pagination fields', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [{ id: 7 }], + total: 1, + page: 1, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + }) + + const error = await client.requestList('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'invalid-response', + status: 200, + message: '后端列表响应格式无效', + }) + }) + + it('reports a non-envelope HTTP failure as an HTTP ApiError', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => new Response('gateway unavailable', { status: 503 }), + }) + + const error = await client.request('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'http', + code: null, + status: 503, + }) + }) + + it('does not accept a success envelope carried by a failed HTTP response', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 200, message: 'success', data: { id: 7 } }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + }) + + const error = await client.request('/resources/7').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ kind: 'http', code: null, status: 500 }) + }) + + it('uses VITE_API_BASE_URL when an explicit base URL is not provided', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test/root/') + let requestUrl = '' + const client = createApiClient({ + fetchFn: async (input) => { + requestUrl = String(input) + return new Response(JSON.stringify({ code: 200, message: 'success', data: null })) + }, + }) + + await client.request('/resources') + + expect(requestUrl).toBe('https://api.windup.test/root/resources') + }) +}) From d522756c9c6c3a5a7e5cba91e62beac965860972 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 02:29:38 +0800 Subject: [PATCH 03/11] docs(frontend): document shared API boundary The architecture docs need to distinguish the new transport layer from business-specific API implementations. Describe shared API ownership, environment configuration, token consumption, response handling, and explicit exclusions. Future entity work can reuse the client without expanding shared-layer responsibilities. --- frontend-architecture-v3.md | 6 ++++-- frontend/README.md | 2 +- frontend/src/shared/README.md | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 13e7a06..bcf3a99 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -37,10 +37,12 @@ pages -> features -> entities -> shared | `pages` | 八个路由页面 | | `features` | 用户操作:角色设置、生成、审核、导出;以及流程推进 `workflow-controller` | | `entities` | 上表业务模块 | -| `shared` | 无业务语义的形状,目前只有分页 | +| `shared` | 无业务语义的分页形状、HTTP 传输与通用 UI | `app` 只做启动和路由,不构造服务、不向下注入。 +`shared/api` 只处理后端所有模块共用的传输契约:从环境读取 API 地址、附加调用方提供的 access token、解包统一响应、识别业务码并转换分页字段。它不知道 Project、Character 等业务 DTO,也不保存 token;各 `XxxApis` 的路径、字段映射与实例仍跟随对应 `entities` 模块。 + 外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏。目前全部路由都在里面,根路由也是——顶栏悬浮在内容之上、不占布局高度,首屏仍是满幅,而首页同样需要通往项目资产的常驻入口。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`;顶栏内部读 pathname 只为高亮当前项,与此无关。外壳也不统一夹居中容器,宽度与留白由页面自己决定:顶栏既然悬浮,避让由页面负责,内容页统一走 `PageContainer`。 ### 依赖规则 @@ -88,7 +90,7 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 ## 5. 尚未包含 -- 真实请求与数据获取,`XxxApis` 目前只有接口 +- 各业务模块的真实请求与数据获取,`XxxApis` 目前只有接口;通用请求能力已由 `shared/api` 提供 - 首页之外的页面实现,其余七个路由仍是占位外壳 - 图片上传模块(体量太小,不单独体现) - 穿戴道具相关(产品侧未设计) diff --git a/frontend/README.md b/frontend/README.md index 2976fd5..c2a14d7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -25,7 +25,7 @@ CI 按上面顺序全跑一遍。 模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 -模块边界与接口已经落地,页面实现按模块拆成多个 PR 陆续进来。**目前只有首页是真实现,其余七个路由仍是占位外壳**,`entities` 与 `features` 也只有类型和 `XxxApis` 接口,没有真实请求。 +模块边界与接口已经落地,页面实现按模块拆成多个 PR 陆续进来。**目前只有首页是真实现,其余七个路由仍是占位外壳**,`entities` 与 `features` 也只有类型和 `XxxApis` 接口;`shared/api` 已提供后续实现可复用的公共 HTTP 请求能力。 页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。 diff --git a/frontend/src/shared/README.md b/frontend/src/shared/README.md index 1b3dddd..4cf1f5f 100644 --- a/frontend/src/shared/README.md +++ b/frontend/src/shared/README.md @@ -9,13 +9,16 @@ ## 现有内容 - `pagination/` —— 与传输协议无关的分页请求与结果形状。 +- `api/` —— 后端公共 HTTP 客户端:统一响应解包、业务码、分页、Bearer 请求头和传输错误。 + +`api/` 默认从 `VITE_API_BASE_URL` 读取服务地址,并在发出请求时调用可选的 `getAccessToken`。它只消费 token,不决定 token 如何登录取得、保存或刷新。 ## 后续允许放入 - `ui/` —— 按钮、弹窗、加载状态等不含业务含义的展示组件 - `hooks/` —— 通用浏览器或 React 行为,例如媒体查询、键盘快捷键 - `utils/` —— 纯函数工具,例如日期格式化、文件大小显示 -- `config/` —— 前端通用常量与运行时配置读取 +- `config/` —— `api/` 之外的前端通用常量与运行时配置读取 **这些目录只在出现真实代码时创建,不为占位提前建空文件。** From d3f620b5ae3240c7f830740408510de90001b97e Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 02:49:09 +0800 Subject: [PATCH 04/11] fix(shared-api): prioritize HTTP transport failures Non-2xx responses could be misclassified as business errors when their envelopes also used non-200 codes. Check the HTTP status before evaluating the backend business code. Transport failures now retain HTTP semantics while HTTP 200 business failures remain unchanged. --- frontend/src/shared/api/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts index 41675fc..678c339 100644 --- a/frontend/src/shared/api/index.ts +++ b/frontend/src/shared/api/index.ts @@ -96,18 +96,18 @@ async function readEnvelope(response: Response): Promise { /** 后端业务异常仍返回 HTTP 200,因此不能只依赖 Response.ok。 */ function assertSuccessfulEnvelope(response: Response, envelope: ApiEnvelope): void { - if (envelope.code !== 200) { - throw new ApiError(envelope.message, { - kind: 'business', - code: envelope.code, + if (!response.ok) { + throw new ApiError('HTTP 请求失败', { + kind: 'http', status: response.status, data: envelope.data, }) } - if (!response.ok) { - throw new ApiError('HTTP 请求失败', { - kind: 'http', + if (envelope.code !== 200) { + throw new ApiError(envelope.message, { + kind: 'business', + code: envelope.code, status: response.status, data: envelope.data, }) From 8d488f2d502ba6b45d9d7f6e68e383bb07826f75 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 02:49:35 +0800 Subject: [PATCH 05/11] test(shared-api): cover HTTP error precedence The transport classifier needs a regression case where both HTTP status and backend business code indicate failure. Add a response fixture with HTTP 503 and business code 500. The test prevents non-2xx responses from regressing to business-error classification. --- frontend/src/shared/api/index.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/frontend/src/shared/api/index.test.ts b/frontend/src/shared/api/index.test.ts index 6d73e3f..0ca7bcf 100644 --- a/frontend/src/shared/api/index.test.ts +++ b/frontend/src/shared/api/index.test.ts @@ -193,6 +193,32 @@ describe('createApiClient', () => { }) }) + it('prioritizes an HTTP failure when the response also has a non-200 business code', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 500, + message: '服务暂时不可用', + data: { request_id: 'request-7' }, + }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ), + }) + + const error = await client.request('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'http', + code: null, + status: 503, + message: 'HTTP 请求失败', + data: { request_id: 'request-7' }, + }) + }) + it('does not accept a success envelope carried by a failed HTTP response', async () => { const client = createApiClient({ baseUrl: 'https://api.windup.test', From 466d3bc9bc8aff56330370f31cc39dd5a16b00a8 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:42:01 +0800 Subject: [PATCH 06/11] feat(api): add access token provider registry Business API clients need a shared lazy token boundary. Register and restore token reader functions without storing token values. Project and character adapters can consume authentication supplied later. --- frontend/src/shared/api/index.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts index 678c339..8031519 100644 --- a/frontend/src/shared/api/index.ts +++ b/frontend/src/shared/api/index.ts @@ -27,6 +27,27 @@ export interface ApiClientOptions { getAccessToken?: () => string | null | undefined } +export type ApiAccessTokenProvider = NonNullable + +const accessTokenProviders: ApiAccessTokenProvider[] = [] + +/** + * 注册登录模块持有的 token 读取函数;这里只保存读取函数,不保存、刷新或解析 token。 + * 返回值用于模块卸载或测试结束时撤销本次注册。 + */ +export function registerApiAccessTokenProvider(provider: ApiAccessTokenProvider): () => void { + accessTokenProviders.push(provider) + return () => { + const index = accessTokenProviders.lastIndexOf(provider) + if (index >= 0) accessTokenProviders.splice(index, 1) + } +} + +/** 业务 API 实例统一传给 createApiClient 的惰性 token 读取边界。 */ +export function getApiAccessToken(): string | null | undefined { + return accessTokenProviders.at(-1)?.() +} + export type ApiErrorKind = 'business' | 'http' | 'invalid-response' | 'network' /** 后端业务错误与传输错误统一进入这一种前端错误。 */ From 3138cd6186c4c0a121142c61b695ac938c1afaf7 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:42:08 +0800 Subject: [PATCH 07/11] test(api): cover access token provider registry The shared token boundary needs deterministic registration behavior. Cover latest-provider selection and restoration after unregistering. Future login integration can rely on the provider lifecycle. --- frontend/src/shared/api/index.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/shared/api/index.test.ts b/frontend/src/shared/api/index.test.ts index 0ca7bcf..2c2dfe4 100644 --- a/frontend/src/shared/api/index.test.ts +++ b/frontend/src/shared/api/index.test.ts @@ -1,10 +1,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { ApiError, createApiClient } from './index' +import { + ApiError, + createApiClient, + getApiAccessToken, + registerApiAccessTokenProvider, +} from './index' afterEach(() => vi.unstubAllEnvs()) describe('createApiClient', () => { + it('reads the latest registered token provider and restores the previous provider', () => { + const unregisterFirst = registerApiAccessTokenProvider(() => 'first-token') + const unregisterSecond = registerApiAccessTokenProvider(() => 'second-token') + + expect(getApiAccessToken()).toBe('second-token') + unregisterSecond() + expect(getApiAccessToken()).toBe('first-token') + unregisterFirst() + expect(getApiAccessToken()).toBeUndefined() + }) + it('returns data from a successful backend response envelope', async () => { const client = createApiClient({ baseUrl: 'https://api.windup.test', From 0930114513fcf42e172f1157afc86663a57a91d9 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:44:19 +0800 Subject: [PATCH 08/11] docs(api): document token provider boundary Shared API documentation needs to describe the consumed authentication edge. Explain provider registration while keeping token ownership outside shared code. Later login work can integrate without redefining the transport layer. --- frontend/src/shared/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/shared/README.md b/frontend/src/shared/README.md index 4cf1f5f..1e7743d 100644 --- a/frontend/src/shared/README.md +++ b/frontend/src/shared/README.md @@ -11,7 +11,7 @@ - `pagination/` —— 与传输协议无关的分页请求与结果形状。 - `api/` —— 后端公共 HTTP 客户端:统一响应解包、业务码、分页、Bearer 请求头和传输错误。 -`api/` 默认从 `VITE_API_BASE_URL` 读取服务地址,并在发出请求时调用可选的 `getAccessToken`。它只消费 token,不决定 token 如何登录取得、保存或刷新。 +`api/` 默认从 `VITE_API_BASE_URL` 读取服务地址,并在发出请求时调用可选的 `getAccessToken`。业务 API 统一使用 `getApiAccessToken`,后续登录模块通过 `registerApiAccessTokenProvider` 注册实际读取函数。公共层只保存这个函数,不决定 token 如何登录取得、保存或刷新。 ## 后续允许放入 From 177788e282955cb477d887324d0f3a3b2bb84f5d Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 03:54:39 +0800 Subject: [PATCH 09/11] docs(api): clarify token provider integration The shared API boundary now exposes a provider registry for future login work. Document the lazy token registration path in the architecture and frontend guide. Business API modules can depend on one stable injection boundary without owning auth state. --- frontend-architecture-v3.md | 2 +- frontend/README.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index bcf3a99..89fcf0c 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -41,7 +41,7 @@ pages -> features -> entities -> shared `app` 只做启动和路由,不构造服务、不向下注入。 -`shared/api` 只处理后端所有模块共用的传输契约:从环境读取 API 地址、附加调用方提供的 access token、解包统一响应、识别业务码并转换分页字段。它不知道 Project、Character 等业务 DTO,也不保存 token;各 `XxxApis` 的路径、字段映射与实例仍跟随对应 `entities` 模块。 +`shared/api` 只处理后端所有模块共用的传输契约:从环境读取 API 地址、附加调用方提供的 access token、解包统一响应、识别业务码并转换分页字段。登录模块通过 `registerApiAccessTokenProvider` 注册惰性读取函数,各业务 API 统一使用 `getApiAccessToken`;公共层只保存读取函数,不保存、刷新或解析 token。各 `XxxApis` 的路径、字段映射与实例仍跟随对应 `entities` 模块。 外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏。目前全部路由都在里面,根路由也是——顶栏悬浮在内容之上、不占布局高度,首屏仍是满幅,而首页同样需要通往项目资产的常驻入口。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`;顶栏内部读 pathname 只为高亮当前项,与此无关。外壳也不统一夹居中容器,宽度与留白由页面自己决定:顶栏既然悬浮,避让由页面负责,内容页统一走 `PageContainer`。 diff --git a/frontend/README.md b/frontend/README.md index c2a14d7..38296e6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -29,4 +29,6 @@ CI 按上面顺序全跑一遍。 页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。 +运行项目前需要配置 `VITE_API_BASE_URL`。Bearer token 由登录模块取得后,通过 `registerApiAccessTokenProvider` 注册读取函数;业务请求统一从该边界读取。本轮不定义 token 的保存方式。 + 与后端尚未对齐的接口见 `API_CONTRACT.md`。 From 7c028d64945776f69acd8561ff320cf325d3b56e Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 04:00:44 +0800 Subject: [PATCH 10/11] docs(api): isolate shared API guidance Projects documentation must merge independently after the shared API pull request. Move transport guidance away from project status and workspace routing text. The two pull requests can update their own documentation without overlapping hunks. --- frontend-architecture-v3.md | 6 +++--- frontend/README.md | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 89fcf0c..34d7cb5 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -41,8 +41,6 @@ pages -> features -> entities -> shared `app` 只做启动和路由,不构造服务、不向下注入。 -`shared/api` 只处理后端所有模块共用的传输契约:从环境读取 API 地址、附加调用方提供的 access token、解包统一响应、识别业务码并转换分页字段。登录模块通过 `registerApiAccessTokenProvider` 注册惰性读取函数,各业务 API 统一使用 `getApiAccessToken`;公共层只保存读取函数,不保存、刷新或解析 token。各 `XxxApis` 的路径、字段映射与实例仍跟随对应 `entities` 模块。 - 外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏。目前全部路由都在里面,根路由也是——顶栏悬浮在内容之上、不占布局高度,首屏仍是满幅,而首页同样需要通往项目资产的常驻入口。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`;顶栏内部读 pathname 只为高亮当前项,与此无关。外壳也不统一夹居中容器,宽度与留白由页面自己决定:顶栏既然悬浮,避让由页面负责,内容页统一走 `PageContainer`。 ### 依赖规则 @@ -52,6 +50,8 @@ pages -> features -> entities -> shared 3. 跨模块只从模块目录的 `index.ts` 进入;`entities` 统一从 `@/entities` 使用。 4. `entities` 内部模块之间可以互相导入,对外仍是一个门。 +`shared/api` 只处理后端所有模块共用的传输契约:从环境读取 API 地址、附加调用方提供的 access token、解包统一响应、识别业务码并转换分页字段。登录模块通过 `registerApiAccessTokenProvider` 注册惰性读取函数,各业务 API 统一使用 `getApiAccessToken`;公共层只保存读取函数,不保存、刷新或解析 token。各 `XxxApis` 的路径、字段映射与实例仍跟随对应 `entities` 模块。 + --- ## 3. 接口命名 @@ -90,7 +90,7 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 ## 5. 尚未包含 -- 各业务模块的真实请求与数据获取,`XxxApis` 目前只有接口;通用请求能力已由 `shared/api` 提供 +- 真实请求与数据获取,`XxxApis` 目前只有接口 - 首页之外的页面实现,其余七个路由仍是占位外壳 - 图片上传模块(体量太小,不单独体现) - 穿戴道具相关(产品侧未设计) diff --git a/frontend/README.md b/frontend/README.md index 38296e6..f64163f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -25,10 +25,12 @@ CI 按上面顺序全跑一遍。 模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 -模块边界与接口已经落地,页面实现按模块拆成多个 PR 陆续进来。**目前只有首页是真实现,其余七个路由仍是占位外壳**,`entities` 与 `features` 也只有类型和 `XxxApis` 接口;`shared/api` 已提供后续实现可复用的公共 HTTP 请求能力。 +模块边界与接口已经落地,页面实现按模块拆成多个 PR 陆续进来。**目前只有首页是真实现,其余七个路由仍是占位外壳**,`entities` 与 `features` 也只有类型和 `XxxApis` 接口,没有真实请求。 页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。 +`shared/api` 提供后续业务接口可复用的公共 HTTP 请求能力。 + 运行项目前需要配置 `VITE_API_BASE_URL`。Bearer token 由登录模块取得后,通过 `registerApiAccessTokenProvider` 注册读取函数;业务请求统一从该边界读取。本轮不定义 token 的保存方式。 与后端尚未对齐的接口见 `API_CONTRACT.md`。 From d888e9a65bf90c2e25644f8ad9d1fd731cd54578 Mon Sep 17 00:00:00 2001 From: huyan Date: Wed, 5 Aug 2026 11:48:17 +0800 Subject: [PATCH 11/11] docs(shared-api): document list response shape Review feedback identified the pagination envelope as an integration hotspot for future entity APIs. Document that list data is an array and pagination fields remain at the envelope top level. Future API implementations now have an explicit parsing boundary without changing runtime behavior. --- frontend/src/shared/api/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts index 8031519..06f8ad8 100644 --- a/frontend/src/shared/api/index.ts +++ b/frontend/src/shared/api/index.ts @@ -202,6 +202,10 @@ export function createApiClient({ return envelope.data as T }, + /** + * 后端 ListResponse 固定为 { data: T[], total, page, page_size }; + * 分页字段与 data 同级,不在 data 内再嵌套分页对象。 + */ async requestList(path: string, options?: ApiRequestOptions) { const response = await send(path, options) const envelope = await readEnvelope(response)