Skip to content
Merged
6 changes: 4 additions & 2 deletions frontend-architecture-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

### 依赖规则
Expand Down Expand Up @@ -88,7 +90,7 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断

## 5. 尚未包含

- 真实请求与数据获取,`XxxApis` 目前只有接口
- 各业务模块的真实请求与数据获取,`XxxApis` 目前只有接口;通用请求能力已由 `shared/api` 提供
- 首页之外的页面实现,其余七个路由仍是占位外壳
- 图片上传模块(体量太小,不单独体现)
- 穿戴道具相关(产品侧未设计)
Expand Down
2 changes: 1 addition & 1 deletion frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ CI 按上面顺序全跑一遍。

模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。

模块边界与接口已经落地,页面实现按模块拆成多个 PR 陆续进来。**目前只有首页是真实现,其余七个路由仍是占位外壳**,`entities` 与 `features` 也只有类型和 `XxxApis` 接口,没有真实请求
模块边界与接口已经落地,页面实现按模块拆成多个 PR 陆续进来。**目前只有首页是真实现,其余七个路由仍是占位外壳**,`entities` 与 `features` 也只有类型和 `XxxApis` 接口;`shared/api` 已提供后续实现可复用的公共 HTTP 请求能力

页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。

Expand Down
5 changes: 4 additions & 1 deletion frontend/src/shared/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
## 现有内容

- `pagination/` —— 与传输协议无关的分页请求与结果形状。
- `api/` —— 后端公共 HTTP 客户端:统一响应解包、业务码、分页、Bearer 请求头和传输错误。

`api/` 默认从 `VITE_API_BASE_URL` 读取服务地址,并在发出请求时调用可选的 `getAccessToken`。它只消费 token,不决定 token 如何登录取得、保存或刷新。

## 后续允许放入

- `ui/` —— 按钮、弹窗、加载状态等不含业务含义的展示组件
- `hooks/` —— 通用浏览器或 React 行为,例如媒体查询、键盘快捷键
- `utils/` —— 纯函数工具,例如日期格式化、文件大小显示
- `config/` —— 前端通用常量与运行时配置读取
- `config/` —— `api/` 之外的前端通用常量与运行时配置读取

**这些目录只在出现真实代码时创建,不为占位提前建空文件。**

Expand Down
226 changes: 226 additions & 0 deletions frontend/src/shared/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading