diff --git a/apps/web/src/layouts/MainLayout.css b/apps/web/src/layouts/MainLayout.css
index 0657489..810437c 100644
--- a/apps/web/src/layouts/MainLayout.css
+++ b/apps/web/src/layouts/MainLayout.css
@@ -102,6 +102,25 @@
gap: 12px;
}
+/* 页头平台选择器:图标 + 无边框下拉,视觉与用户触发区一致 */
+.platform-selector {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: 6px;
+ transition: background 0.2s;
+}
+
+.platform-selector:hover {
+ background: #f3f4f6;
+}
+
+.platform-selector-icon {
+ color: var(--primary-color);
+ font-size: 15px;
+}
+
/* 头像 + 用户名触发区:hover 有浅色背景反馈,暗示可下拉 */
.header-user-trigger {
display: flex;
diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx
index 56f8400..26683aa 100644
--- a/apps/web/src/layouts/MainLayout.tsx
+++ b/apps/web/src/layouts/MainLayout.tsx
@@ -10,6 +10,7 @@ import {
UserOutlined,
} from '@ant-design/icons';
import SidebarMenu from './SidebarMenu';
+import PlatformSelector from './PlatformSelector';
import { getBreadcrumbItems } from './menuConfig';
import { useSession } from '@/services/session';
import './MainLayout.css';
@@ -156,6 +157,7 @@ const MainLayout = () => {
{session && (
+
{
+ const {
+ platforms,
+ currentPlatform,
+ selectPlatform,
+ context,
+ contextLoading,
+ } = usePlatform();
+
+ if (platforms.length === 0) return null;
+
+ const tooltip = context?.default_store
+ ? `当前平台:${currentPlatform?.name},默认店铺:${context.default_store.name}`
+ : '选择当前平台';
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default PlatformSelector;
diff --git a/apps/web/src/pages/shops/ShopsPage.css b/apps/web/src/pages/shops/ShopsPage.css
new file mode 100644
index 0000000..4c6369a
--- /dev/null
+++ b/apps/web/src/pages/shops/ShopsPage.css
@@ -0,0 +1,11 @@
+.shops-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.shops-load-more {
+ display: flex;
+ justify-content: center;
+ margin-top: 16px;
+}
diff --git a/apps/web/src/pages/shops/ShopsPage.tsx b/apps/web/src/pages/shops/ShopsPage.tsx
index 0d2ff1f..f0bad15 100644
--- a/apps/web/src/pages/shops/ShopsPage.tsx
+++ b/apps/web/src/pages/shops/ShopsPage.tsx
@@ -1,8 +1,624 @@
-import PagePlaceholder from '@/components/PagePlaceholder';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ App as AntdApp,
+ Button,
+ Card,
+ Dropdown,
+ Empty,
+ Form,
+ Input,
+ Modal,
+ Select,
+ Space,
+ Switch,
+ Table,
+ Tag,
+ Tooltip,
+ Typography,
+} from 'antd';
+import type { MenuProps, TableProps } from 'antd';
+import {
+ ApiOutlined,
+ CloudDownloadOutlined,
+ DeleteOutlined,
+ MoreOutlined,
+ PlusOutlined,
+ ReloadOutlined,
+ WarningFilled,
+} from '@ant-design/icons';
+import axios from 'axios';
+import type { Store } from '@atlas/contracts';
+import { apiErrorCode, apiErrorMessage } from '@/services/api';
+import { storesApi } from '@/services/stores';
+import { usePlatform } from '@/services/platform';
+import './ShopsPage.css';
-/** 店铺管理:绑定平台店铺授权与水印(业务模块尚未实现) */
-const ShopsPage = () => (
-
-);
+const STORE_STATUS_MAP: Record = {
+ verifying: { color: 'gold', text: '验证中' },
+ active: { color: 'green', text: '正常' },
+ credential_invalid: { color: 'red', text: '凭据无效' },
+ sync_error: { color: 'orange', text: '同步异常' },
+ disabled: { color: 'default', text: '已停用' },
+ deleted: { color: 'default', text: '已删除' },
+};
+
+const CREDENTIAL_STATUS_MAP: Record = {
+ unknown: { color: 'default', text: '未验证' },
+ valid: { color: 'green', text: '凭据有效' },
+ invalid: { color: 'red', text: '凭据无效' },
+ expired: { color: 'red', text: '已过期' },
+ rotation_required: { color: 'gold', text: '待轮换' },
+};
+
+function formatDateTime(value: string | null): string {
+ if (!value) return '—';
+ const date = new Date(value);
+ return Number.isNaN(date.getTime())
+ ? '—'
+ : date.toLocaleString('zh-CN', { hour12: false });
+}
+
+interface CreateFormValues {
+ name: string;
+ client_id: string;
+ api_key: string;
+ currency_code: string;
+ is_default: boolean;
+ note?: string;
+}
+
+interface EditFormValues {
+ name: string;
+ currency_code: string;
+ note?: string;
+ api_key?: string;
+}
+
+/**
+ * 店铺管理页,契约见 docs/api/stores-and-platforms.md。
+ * 列表按页头当前平台过滤(platform_id 查询参数);创建与密钥替换为异步验证,
+ * Worker 消费验证操作前店铺会停留在「验证中」状态。
+ */
+const ShopsPage = () => {
+ const { message, modal } = AntdApp.useApp();
+ const {
+ currentPlatform,
+ platforms,
+ error: platformError,
+ reload,
+ refreshContext,
+ } = usePlatform();
+
+ const [stores, setStores] = useState([]);
+ const [nextCursor, setNextCursor] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [pendingStoreId, setPendingStoreId] = useState(null);
+
+ const [createOpen, setCreateOpen] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [createForm] = Form.useForm();
+
+ const [editingStore, setEditingStore] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [editForm] = Form.useForm();
+
+ const platformId = currentPlatform?.id ?? null;
+ const currencyOptions = useMemo(
+ () =>
+ (currentPlatform?.supported_currencies ?? []).map((code) => ({
+ value: code,
+ label: code,
+ })),
+ [currentPlatform]
+ );
+
+ const loadStores = useCallback(
+ async (targetPlatformId: string) => {
+ setLoading(true);
+ try {
+ const result = await storesApi.list(targetPlatformId);
+ setStores(result.data);
+ setNextCursor(result.meta.next_cursor);
+ } catch (err) {
+ message.error(apiErrorMessage(err));
+ } finally {
+ setLoading(false);
+ }
+ },
+ [message]
+ );
+
+ useEffect(() => {
+ if (!platformId) return;
+ void loadStores(platformId);
+ }, [platformId, loadStores]);
+
+ const loadMore = async () => {
+ if (!platformId || !nextCursor) return;
+ setLoadingMore(true);
+ try {
+ const result = await storesApi.list(platformId, nextCursor);
+ setStores((prev) => [...prev, ...result.data]);
+ setNextCursor(result.meta.next_cursor);
+ } catch (err) {
+ message.error(apiErrorMessage(err));
+ } finally {
+ setLoadingMore(false);
+ }
+ };
+
+ /** 任何变更后同时刷新列表与页头默认门店摘要 */
+ const refreshAll = useCallback(async () => {
+ if (!platformId) return;
+ await loadStores(platformId);
+ await refreshContext();
+ }, [platformId, loadStores, refreshContext]);
+
+ const openCreate = () => {
+ createForm.resetFields();
+ createForm.setFieldsValue({ is_default: stores.length === 0 });
+ setCreateOpen(true);
+ };
+
+ const handleCreate = async (values: CreateFormValues) => {
+ if (!currentPlatform) return;
+ setCreating(true);
+ try {
+ await storesApi.create({
+ platform_id: currentPlatform.id,
+ name: values.name.trim(),
+ client_id: values.client_id.trim(),
+ api_key: values.api_key,
+ currency_code: values.currency_code,
+ is_default: values.is_default,
+ note: values.note?.trim() ? values.note.trim() : null,
+ });
+ message.success('店铺已创建,凭据验证进行中,稍后可刷新查看结果');
+ setCreateOpen(false);
+ await refreshAll();
+ } catch (err) {
+ message.error(apiErrorMessage(err));
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const openEdit = (store: Store) => {
+ setEditingStore(store);
+ editForm.setFieldsValue({
+ name: store.name,
+ currency_code: store.currency_code,
+ note: store.note ?? '',
+ });
+ };
+
+ const handleEditSave = async (values: EditFormValues) => {
+ if (!editingStore) return;
+ setSaving(true);
+ try {
+ const result = await storesApi.update(
+ editingStore.id,
+ {
+ name: values.name.trim(),
+ currency_code: values.currency_code,
+ note: values.note?.trim() ? values.note.trim() : null,
+ // 留空表示保留旧密钥;填写则替换并重新触发验证
+ ...(values.api_key ? { api_key: values.api_key } : {}),
+ },
+ { revision: editingStore.revision }
+ );
+ if (result && 'operation' in result) {
+ message.success('已保存,新密钥验证进行中,稍后可刷新查看结果');
+ } else {
+ message.success('店铺已保存');
+ }
+ setEditingStore(null);
+ await refreshAll();
+ } catch (err) {
+ // 乐观锁版本过期:提示后刷新列表,用户重新编辑拿到新 revision
+ if (axios.isAxiosError(err) && err.response?.status === 409) {
+ message.error('店铺信息已被其他操作更新,列表已刷新,请重新编辑');
+ setEditingStore(null);
+ await refreshAll();
+ } else {
+ message.error(apiErrorMessage(err));
+ }
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const withPending = async (store: Store, action: () => Promise) => {
+ setPendingStoreId(store.id);
+ try {
+ await action();
+ await refreshAll();
+ } catch (err) {
+ message.error(apiErrorMessage(err));
+ } finally {
+ setPendingStoreId(null);
+ }
+ };
+
+ const handleSetDefault = (store: Store) =>
+ withPending(store, async () => {
+ await storesApi.setDefault(store.id);
+ message.success(`已将「${store.name}」设为默认店铺`);
+ });
+
+ const handleVerify = (store: Store) =>
+ withPending(store, async () => {
+ await storesApi.requestCredentialVerification(store.id);
+ message.success('已发起凭据验证,稍后可刷新查看结果');
+ });
+
+ const handleSyncWarehouses = (store: Store) =>
+ withPending(store, async () => {
+ await storesApi.requestWarehouseSync(store.id);
+ message.success('已发起仓库同步,稍后可刷新查看结果');
+ });
+
+ const handleDelete = (store: Store) => {
+ modal.confirm({
+ title: '删除店铺',
+ content: `仅删除 Atlas 内的店铺连接,不影响平台后台。确定删除「${store.name}」吗?`,
+ okText: '删除',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: () =>
+ withPending(store, async () => {
+ try {
+ await storesApi.remove(store.id);
+ message.success('店铺已删除');
+ } catch (err) {
+ if (apiErrorCode(err) === 'DEFAULT_STORE_REPLACEMENT_REQUIRED') {
+ message.error(
+ '该店铺是默认店铺,请先将同平台的其他店铺设为默认,再删除'
+ );
+ return;
+ }
+ throw err;
+ }
+ }),
+ });
+ };
+
+ const rowMenu = (store: Store): MenuProps['items'] => [
+ {
+ key: 'set-default',
+ icon: ,
+ label: '设为默认店铺',
+ disabled: store.is_default,
+ },
+ { key: 'verify', icon: , label: '重新验证凭据' },
+ { key: 'sync', icon: , label: '同步仓库' },
+ { type: 'divider' },
+ { key: 'delete', icon: , label: '删除店铺', danger: true },
+ ];
+
+ const handleRowMenuClick =
+ (store: Store): MenuProps['onClick'] =>
+ ({ key }) => {
+ if (key === 'set-default') void handleSetDefault(store);
+ else if (key === 'verify') void handleVerify(store);
+ else if (key === 'sync') void handleSyncWarehouses(store);
+ else if (key === 'delete') handleDelete(store);
+ };
+
+ const columns: TableProps['columns'] = [
+ {
+ title: '店铺名称',
+ dataIndex: 'name',
+ width: 220,
+ render: (_, store) => (
+
+ {store.name}
+ {store.is_default && 默认}
+
+ ),
+ },
+ {
+ title: 'Client ID',
+ dataIndex: 'platform_client_id',
+ width: 150,
+ render: (value: string) => (
+ {value}
+ ),
+ },
+ {
+ title: '币种',
+ dataIndex: 'currency_code',
+ width: 80,
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 200,
+ render: (_, store) => {
+ const status = STORE_STATUS_MAP[store.status] ?? {
+ color: 'default',
+ text: store.status,
+ };
+ const credential = CREDENTIAL_STATUS_MAP[store.credential_status] ?? {
+ color: 'default',
+ text: store.credential_status,
+ };
+ return (
+
+ {status.text}
+ {credential.text}
+ {store.last_error && (
+
+
+
+ )}
+
+ );
+ },
+ },
+ {
+ title: '仓库',
+ dataIndex: 'warehouse_count',
+ width: 90,
+ render: (count: number, store) => (
+
+ {store.warehouses_synced_at ? count : '—'}
+
+ ),
+ },
+ {
+ title: '水印模板',
+ dataIndex: 'watermark_template',
+ width: 130,
+ render: (template: Store['watermark_template']) =>
+ template ? (
+ template.name
+ ) : (
+ 未绑定
+ ),
+ },
+ {
+ title: '备注',
+ dataIndex: 'note',
+ ellipsis: true,
+ render: (note: string | null) =>
+ note ? (
+ {note}
+ ) : (
+ —
+ ),
+ },
+ {
+ title: '更新时间',
+ dataIndex: 'updated_at',
+ width: 170,
+ render: (value: string) => (
+
+ {formatDateTime(value)}
+
+ ),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 140,
+ fixed: 'right',
+ render: (_, store) => (
+
+
+
+ }
+ loading={pendingStoreId === store.id}
+ />
+
+
+ ),
+ },
+ ];
+
+ if (platformError) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
export default ShopsPage;
diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx
index 31b388b..a03c0ab 100644
--- a/apps/web/src/router/index.tsx
+++ b/apps/web/src/router/index.tsx
@@ -12,6 +12,7 @@ import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
import RegisterPage from '@/pages/auth/RegisterPage';
import ShopsPage from '@/pages/shops/ShopsPage';
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
+import { PlatformProvider } from '@/services/platform';
import { useSession } from '@/services/session';
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
@@ -45,7 +46,10 @@ export const router = createBrowserRouter([
path: '/',
element: (
-
+ {/* 平台上下文仅在登录后可用,随主布局挂载 */}
+
+
+
),
children: [
diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts
index dbd1e59..abe47a7 100644
--- a/apps/web/src/services/api.ts
+++ b/apps/web/src/services/api.ts
@@ -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(response: AxiosResponse): T {
const body = response.data as { data?: unknown } | undefined;
@@ -91,10 +91,22 @@ function unwrapEnvelope(response: AxiosResponse): T {
return body.data as T;
}
+/** 列表响应信封为 { data: T[], meta: PaginationMeta }(docs/api/conventions.md §3),meta 需要原样保留 */
+function unwrapListEnvelope(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: (url: string, config?: AxiosRequestConfig) =>
apiClient.get(url, config).then((res) => unwrapEnvelope(res)),
+ getList: (url: string, config?: AxiosRequestConfig) =>
+ apiClient.get(url, config).then((res) => unwrapListEnvelope(res)),
+
post: (url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.post(url, data, config).then((res) => unwrapEnvelope(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;
diff --git a/apps/web/src/services/platform.tsx b/apps/web/src/services/platform.tsx
new file mode 100644
index 0000000..ba0ee37
--- /dev/null
+++ b/apps/web/src/services/platform.tsx
@@ -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;
+ /** 目录加载失败后重试 */
+ reload: () => void;
+}
+
+const PlatformContextContext = createContext(null);
+
+/** 平台上下文:页头平台选择器的数据源,门店等模块据此确定当前平台(目录接口将 Ozon 排在最前) */
+export function PlatformProvider({ children }: { children: ReactNode }) {
+ const [platforms, setPlatforms] = useState([]);
+ const [selectedId, setSelectedId] = useState(() =>
+ localStorage.getItem(PLATFORM_STORAGE_KEY)
+ );
+ const [error, setError] = useState(null);
+ const [context, setContext] = useState(null);
+ const [contextLoading, setContextLoading] = useState(false);
+ // 快速切换平台时丢弃过期响应与 loading:只接受最后一次请求的结果
+ const contextRequestIdRef = useRef(0);
+
+ const loadPlatforms = useCallback(async () => {
+ setError(null);
+ try {
+ const list = await api.get('/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('/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 (
+
+ {children}
+
+ );
+}
+
+export function usePlatform(): PlatformContextValue {
+ const context = useContext(PlatformContextContext);
+ if (!context) {
+ throw new Error('usePlatform 必须在 PlatformProvider 内使用');
+ }
+ return context;
+}
diff --git a/apps/web/src/services/stores.ts b/apps/web/src/services/stores.ts
new file mode 100644
index 0000000..1d48f15
--- /dev/null
+++ b/apps/web/src/services/stores.ts
@@ -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('/stores', {
+ params: { platform_id: platformId, limit: 20, ...(cursor ? { cursor } : {}) },
+ }),
+
+ get: (storeId: string) => api.get(`/stores/${storeId}`),
+
+ /** 创建后店铺处于 verifying 状态,凭据验证由后台异步执行 */
+ create: (payload: CreateStoreRequest) =>
+ api.post('/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(`/stores/${storeId}`, payload, {
+ headers: {
+ 'If-Match': `"${options.revision}"`,
+ 'Idempotency-Key': crypto.randomUUID(),
+ },
+ }),
+
+ setDefault: (storeId: string) =>
+ api.post(`/stores/${storeId}/set-default`, undefined, {
+ headers: { 'Idempotency-Key': crypto.randomUUID() },
+ }),
+
+ requestCredentialVerification: (storeId: string) =>
+ api.post(`/stores/${storeId}/credential-verifications`, undefined, {
+ headers: { 'Idempotency-Key': crypto.randomUUID() },
+ }),
+
+ requestWarehouseSync: (storeId: string) =>
+ api.post(`/stores/${storeId}/warehouse-syncs`, undefined, {
+ headers: { 'Idempotency-Key': crypto.randomUUID() },
+ }),
+
+ /** 删除当前默认店铺时同平台还有其他店铺会返回 422 DEFAULT_STORE_REPLACEMENT_REQUIRED */
+ remove: (storeId: string) =>
+ api.delete(`/stores/${storeId}`, {
+ headers: { 'Idempotency-Key': crypto.randomUUID() },
+ }),
+};
diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md
index 991f705..36f1130 100644
--- a/docs/project/HANDOFF.md
+++ b/docs/project/HANDOFF.md
@@ -6,7 +6,7 @@
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
-当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 已实现健康/readiness、集中配置与安全基础、认证、个人工作空间上下文、平台目录/上下文、门店基础管理和操作查询;真实平台验证与同步 Worker 尚未接入。Web 已具备完整前端框架(布局、路由、主题)和登录/注册页面,业务页面均为占位;Worker 与扩展为可运行骨架。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
+当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 已实现健康/readiness、集中配置与安全基础、认证、个人工作空间上下文、平台目录/上下文、门店基础管理和操作查询;真实平台验证与同步 Worker 尚未接入。Web 已具备完整前端框架(布局、路由、主题)、登录/注册页、页头平台选择器与店铺管理页(已接入真实 API),其余业务页面为占位;Worker 与扩展为可运行骨架。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
@@ -36,6 +36,17 @@
- Worker:`ioredis` 是 BullMQ 的可选 peer 依赖,pnpm 严格隔离下必须由应用显式声明,已新增 `ioredis 6.0.0`;`main.ts` 改为显式构造 IORedis 实例并设置 `maxRetriesPerRequest: null`(原生 ESM 运行必需,也是后续 BullMQ Worker 的硬性要求)。`tsx` dev 与 `node dist/main.js` 两种运行方式均验证可连接本地 Redis。
- 扩展:WXT dev server 从默认的 3000–3010 漂移改为固定 `8110`(`EXTENSION_PORT` 可覆盖,`strictPort` 开启);`.output/` 构建产物已加入 `.gitignore` 并移出 git 跟踪。端口约定同步更新至交接、部署与项目结构三份文档及 `.env.example`。
+### 1.2 开发进度(2026-09-03 下午):店铺管理与平台上下文前端
+
+**Web 接入平台上下文与门店管理 API(契约:[`api/stores-and-platforms.md`](../api/stores-and-platforms.md))**
+
+- 页头新增平台选择器:`services/platform.tsx` 的 `PlatformProvider` 加载 `GET /api/platforms` 目录与 `GET /api/platform-context`(`X-Atlas-Platform-Id` header),默认取目录第一家(Ozon 优先);选择结果仅在 localStorage 保存平台 id(非敏感 UI 偏好)。页头组件 `layouts/PlatformSelector.tsx`,tooltip 展示当前平台默认店铺。
+- 店铺管理页 `pages/shops/ShopsPage.tsx` 覆盖已实现的门店接口:游标分页列表(“加载更多”)、创建(202 + operation,提示“验证中”)、编辑(`If-Match` 乐观锁,409 时刷新列表并提示重新编辑)、设为默认、手动重新验证凭据、仓库同步(凭据未验证时展示 422 脱敏错误)、软删除(`DEFAULT_STORE_REPLACEMENT_REQUIRED` 转为可操作提示)。API Key 只存在于提交瞬间的内存表单,不写入任何存储。凭据验证与仓库同步的实际执行仍依赖未实现的 Outbox Dispatcher + Worker,店铺会停留在“验证中”。
+- 基础设施:`services/api.ts` 新增 `getList`(保留 `{ data, meta }` 分页信封)与 `apiErrorCode`(业务错误码提取);`services/stores.ts` 封装门店接口并按契约生成 `Idempotency-Key`。
+- `@atlas/contracts` 补充只读响应类型 `Platform`、`PlatformContext`、`CursorPaginationMeta`、`StoreOperationResponse`(前端 AI 按冻结契约补类型,无行为变更,后端请复核)。
+- 修复 API 阻断性 bug(跨领域改动,原因:阻断本功能联调;影响 `apps/api/src/stores/stores.controller.ts`):tsx/esbuild 不生成 `design:paramtypes` 装饰器元数据,控制器裸类型构造参数在 dev 运行时注入 `undefined`,`GET /api/platforms` 返回 500。已改为与 `auth.controller.ts` 一致的显式 `@Inject(...)`;服务层原本就使用显式 token,不受影响。
+- 浏览器实测(全新迁移的本地 PostgreSQL 18 + Redis 7):注册 → 平台选择器 → 创建/编辑/设默认/删除保护/同步 422 错误路径 → 刷新后会话恢复与列表持久化,全部通过;contracts/api/web typecheck 与 web production build 通过。
+
## 2. 已确认产品决策
| 主题 | 决策 |
@@ -98,7 +109,7 @@ User → Workspace → Membership / Role
1. 实现 Outbox Dispatcher 与 Worker 的首个消费链路:将 `store_credential_verification` 投递到 Worker,解密密钥后调用平台适配器,回写门店/密钥状态;失败信息必须脱敏。
2. 在凭据验证通过后实现仓库同步 Worker、幂等 upsert 和状态回写,并补充失败重试、死信与集成测试。
-3. Web 接入真实认证和门店管理 API:登录/注册、会话刷新、平台上下文、门店列表/创建/编辑/默认切换;前端不得保存 API Key 或 refresh token。
+3. ~~Web 接入真实认证和门店管理 API~~ 已基本完成:登录/注册、会话刷新、平台上下文、门店列表/创建/编辑/默认切换/删除均已上线(见 1.2);剩余水印绑定入口待素材/水印模块交付后接入。前端不得保存 API Key 或 refresh token。
4. 接着实现素材直传和水印管理,再开始采集箱与商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
diff --git a/packages/contracts/src/stores.ts b/packages/contracts/src/stores.ts
index bf5e127..34e7fc0 100644
--- a/packages/contracts/src/stores.ts
+++ b/packages/contracts/src/stores.ts
@@ -75,3 +75,37 @@ export type Operation = {
completed_at: string | null;
error: { code: string; message: string } | null;
};
+
+/** GET /api/platforms 返回的平台目录项,能力字段无配置时为 null(docs/api/stores-and-platforms.md §1)。 */
+export type Platform = {
+ id: string;
+ code: string;
+ name: string;
+ supported_currencies: string[];
+ default_market_code: string | null;
+ default_locale: string | null;
+};
+
+/** GET /api/platform-context 返回的页头上下文;没有默认店铺不是错误。 */
+export type PlatformContext = {
+ platform: { id: string; code: string; name: string };
+ default_store: {
+ id: string;
+ name: string;
+ status: string;
+ credential_status: string;
+ } | null;
+ store_count: number;
+};
+
+/** 游标分页 meta(docs/api/conventions.md),next_cursor 为 null 表示没有更多数据。 */
+export type CursorPaginationMeta = {
+ next_cursor: string | null;
+ has_more: boolean;
+};
+
+/** 创建门店与重新验证凭据等异步动作的 202 响应。 */
+export type StoreOperationResponse = {
+ store: Store;
+ operation: Operation;
+};