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;
+145
View File
@@ -0,0 +1,145 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import type { Platform, PlatformContext } from '@atlas/contracts';
import { api, apiErrorMessage } from '@/services/api';
/** 平台选择的本地记忆键:仅保存平台 id 这一非敏感 UI 偏好 */
const PLATFORM_STORAGE_KEY = 'atlas.platform_id';
interface PlatformContextValue {
/** 已启用的平台目录;加载失败时为空数组并置 error */
platforms: Platform[];
/** 页头当前选中的平台;目录未加载完成或为空时为 null */
currentPlatform: Platform | null;
/** 目录加载失败信息;非空时提供重试入口 */
error: string | null;
/** 当前平台的默认门店上下文(GET /api/platform-context) */
context: PlatformContext | null;
contextLoading: boolean;
selectPlatform: (platformId: string) => void;
/** 门店变更后刷新默认门店摘要 */
refreshContext: () => Promise<void>;
/** 目录加载失败后重试 */
reload: () => void;
}
const PlatformContextContext = createContext<PlatformContextValue | null>(null);
/** 平台上下文:页头平台选择器的数据源,门店等模块据此确定当前平台(目录接口将 Ozon 排在最前) */
export function PlatformProvider({ children }: { children: ReactNode }) {
const [platforms, setPlatforms] = useState<Platform[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(() =>
localStorage.getItem(PLATFORM_STORAGE_KEY)
);
const [error, setError] = useState<string | null>(null);
const [context, setContext] = useState<PlatformContext | null>(null);
const [contextLoading, setContextLoading] = useState(false);
// 快速切换平台时丢弃过期响应与 loading:只接受最后一次请求的结果
const contextRequestIdRef = useRef(0);
const loadPlatforms = useCallback(async () => {
setError(null);
try {
const list = await api.get<Platform[]>('/platforms');
setPlatforms(list);
// 已保存的选择失效(平台下线或首次使用)时回退到目录第一家(Ozon 优先)
setSelectedId((prev) =>
prev && list.some((item) => item.id === prev) ? prev : (list[0]?.id ?? null)
);
} catch (err) {
setError(apiErrorMessage(err));
}
}, []);
useEffect(() => {
void loadPlatforms();
}, [loadPlatforms]);
const currentPlatform = useMemo(
() => platforms.find((item) => item.id === selectedId) ?? null,
[platforms, selectedId]
);
const loadContext = useCallback(async (platformId: string) => {
const requestId = ++contextRequestIdRef.current;
setContextLoading(true);
try {
const next = await api.get<PlatformContext>('/platform-context', {
headers: { 'X-Atlas-Platform-Id': platformId },
});
if (contextRequestIdRef.current !== requestId) return;
setContext(next);
} catch {
// 上下文读取失败不阻塞页面:门店页等业务接口自会报错
if (contextRequestIdRef.current !== requestId) return;
setContext(null);
} finally {
if (contextRequestIdRef.current === requestId) {
setContextLoading(false);
}
}
}, []);
useEffect(() => {
if (!currentPlatform) {
setContext(null);
return;
}
void loadContext(currentPlatform.id);
}, [currentPlatform, loadContext]);
const selectPlatform = useCallback((platformId: string) => {
setSelectedId(platformId);
localStorage.setItem(PLATFORM_STORAGE_KEY, platformId);
}, []);
const refreshContext = useCallback(async () => {
if (!currentPlatform) return;
await loadContext(currentPlatform.id);
}, [currentPlatform, loadContext]);
const value = useMemo(
() => ({
platforms,
currentPlatform,
error,
context,
contextLoading,
selectPlatform,
refreshContext,
reload: () => void loadPlatforms(),
}),
[
platforms,
currentPlatform,
error,
context,
contextLoading,
selectPlatform,
refreshContext,
loadPlatforms,
]
);
return (
<PlatformContextContext.Provider value={value}>
{children}
</PlatformContextContext.Provider>
);
}
export function usePlatform(): PlatformContextValue {
const context = useContext(PlatformContextContext);
if (!context) {
throw new Error('usePlatform 必须在 PlatformProvider 内使用');
}
return context;
}
+74
View File
@@ -0,0 +1,74 @@
import type {
CreateStoreRequest,
CursorPaginationMeta,
Store,
StoreOperationResponse,
UpdateStoreRequest,
} from '@atlas/contracts';
import { api } from '@/services/api';
export interface StoreListResult {
data: Store[];
meta: CursorPaginationMeta;
}
export interface UpdateStoreOptions {
/** 提交时的乐观锁版本,取自门店资源 revision */
revision: number;
}
/**
* 门店管理接口,契约见 docs/api/stores-and-platforms.md。
* 列表必须用 platform_id 查询参数(管理列表禁止携带 X-Atlas-Platform-Id header)。
*/
export const storesApi = {
list: (platformId: string, cursor?: string) =>
api.getList<Store, CursorPaginationMeta>('/stores', {
params: { platform_id: platformId, limit: 20, ...(cursor ? { cursor } : {}) },
}),
get: (storeId: string) => api.get<Store>(`/stores/${storeId}`),
/** 创建后店铺处于 verifying 状态,凭据验证由后台异步执行 */
create: (payload: CreateStoreRequest) =>
api.post<StoreOperationResponse>('/stores', payload, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
/**
* 编辑:If-Match 乐观锁。未传 api_key 返回更新后的店铺;
* 传入新 api_key 返回 202 与验证 operation(响应仍是 { store, operation })。
*/
update: (
storeId: string,
payload: UpdateStoreRequest,
options: UpdateStoreOptions
) =>
api.patch<Store | StoreOperationResponse>(`/stores/${storeId}`, payload, {
headers: {
'If-Match': `"${options.revision}"`,
'Idempotency-Key': crypto.randomUUID(),
},
}),
setDefault: (storeId: string) =>
api.post<Store>(`/stores/${storeId}/set-default`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
requestCredentialVerification: (storeId: string) =>
api.post<StoreOperationResponse>(`/stores/${storeId}/credential-verifications`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
requestWarehouseSync: (storeId: string) =>
api.post<StoreOperationResponse>(`/stores/${storeId}/warehouse-syncs`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
/** 删除当前默认店铺时同平台还有其他店铺会返回 422 DEFAULT_STORE_REPLACEMENT_REQUIRED */
remove: (storeId: string) =>
api.delete<void>(`/stores/${storeId}`, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
};