feat(web): 店铺管理页与页头平台选择器接入真实 API

- 页头平台选择器:PlatformProvider 加载平台目录与平台上下文
  (X-Atlas-Platform-Id header),平台 id 仅作为非敏感 UI 偏好存
  localStorage,默认取目录第一家(Ozon 优先)
- 店铺管理页覆盖 docs/api/stores-and-platforms.md 已实现接口:
  游标分页列表、创建(202+operation)、编辑(If-Match 乐观锁、
  409 刷新提示)、设为默认、重新验证凭据、仓库同步(422 脱敏
  错误展示)、软删除(DEFAULT_STORE_REPLACEMENT_REQUIRED 提示)
- services/api.ts 新增 getList(保留 {data, meta} 分页信封)与
  apiErrorCode;services/stores.ts 按契约生成 Idempotency-Key
- @atlas/contracts 补充只读响应类型 Platform / PlatformContext /
  CursorPaginationMeta / StoreOperationResponse(无行为变更)
- API Key 仅存在于提交瞬间的内存表单,不落任何存储
- 浏览器实测:注册→平台切换→创建/编辑/设默认/删除保护/同步
  错误路径→刷新会话恢复全部通过;typecheck + production build 通过
This commit is contained in:
2026-09-03 21:40:02 +08:00
parent 8655b7805f
commit 446baf5939
11 changed files with 991 additions and 9 deletions
+21 -1
View File
@@ -81,7 +81,7 @@ apiClient.interceptors.response.use(
/**
* 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体
* (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身;
* 未来列表接口({ data, meta })在此扩展专门的解包方法以保留 meta。
* 列表接口使用 getList 保留分页 meta。
*/
function unwrapEnvelope<T>(response: AxiosResponse): T {
const body = response.data as { data?: unknown } | undefined;
@@ -91,10 +91,22 @@ function unwrapEnvelope<T>(response: AxiosResponse): T {
return body.data as T;
}
/** 列表响应信封为 { data: T[], meta: PaginationMeta }(docs/api/conventions.md §3),meta 需要原样保留 */
function unwrapListEnvelope<T, M>(response: AxiosResponse): { data: T[]; meta: M } {
const body = response.data as { data?: unknown; meta?: unknown } | undefined;
return {
data: Array.isArray(body?.data) ? (body.data as T[]) : [],
meta: (body?.meta ?? {}) as M,
};
}
export const api = {
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.get<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
getList: <T = unknown, M = unknown>(url: string, config?: AxiosRequestConfig) =>
apiClient.get(url, config).then((res) => unwrapListEnvelope<T, M>(res)),
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
@@ -118,6 +130,14 @@ export function apiErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** 提取业务错误码(如 VERSION_CONFLICT、DEFAULT_STORE_REPLACEMENT_REQUIRED),无则返回 null */
export function apiErrorCode(error: unknown): string | null {
if (!axios.isAxiosError(error)) return null;
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error
?.code;
return typeof code === 'string' ? code : null;
}
export interface ApiErrorDetail {
field: string;
code: string;