import { useCallback, useEffect, useMemo, useRef, 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, } from '@ant-design/icons'; import axios from 'axios'; import type { Store, WatermarkTemplate } from '@atlas/contracts'; import { apiErrorCode, apiErrorMessage } from '@/services/api'; import { storesApi } from '@/services/stores'; import { watermarksApi } from '@/services/watermarks'; import { operationsApi, OPERATION_TERMINAL_STATUSES } from '@/services/operations'; import { usePlatform } from '@/services/platform'; import './ShopsPage.css'; 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: '已删除' }, }; interface StoreStatusView { color: string; text: string; /** 状态附注,如「等待平台验证结果」或凭据验证的脱敏原因 */ note?: string; } /** * 把门店生命周期状态(status)与凭据状态(credential_status)派生为单一展示状态, * 避免两个内部状态并列渲染造成「验证中 + 未验证」的误导: * - verifying(或凭据仍 unknown,Atlas 尚未拿到验证结果):只显示「验证中」并附注 * - active 且凭据 valid:正常 * - 凭据验证失败:401 → 凭据无效;403 → 访问被拒绝(提示检查 Client ID/API Key 归属与权限) * - 同步异常:直接展示 last_error.message——HTTP 400/404/422、超时/429/5xx 等 * 验证请求失败不代表 API Key 无效(OZON_VERIFICATION_UNAVAILABLE 的消息即「暂时无法验证」) * 未穷举的组合按凭据状态(过期/待轮换)或生命周期兜底。 */ function deriveStoreStatusView(store: Store): StoreStatusView { const { status, credential_status: credential, last_error } = store; if (status === 'verifying' || credential === 'unknown') { return { color: 'gold', text: '验证中', note: '等待平台验证结果' }; } if (status === 'credential_invalid' || credential === 'invalid') { if (last_error?.code === 'OZON_CREDENTIAL_FORBIDDEN') { return { color: 'red', text: '访问被拒绝', note: '请检查 Client ID、API Key 是否同账户,以及密钥是否有读取权限', }; } return { color: 'red', text: '凭据无效' }; } if (status === 'sync_error') { return { color: 'orange', text: '同步异常', note: last_error?.message ?? '同步过程出现异常,可重新发起仓库同步或凭据验证', }; } if (status === 'active' && credential === 'valid') { return { color: 'green', text: '正常' }; } if (credential === 'expired') { return { color: 'red', text: '凭据已过期' }; } if (credential === 'rotation_required') { return { color: 'gold', text: '凭据待轮换' }; } const fallback = STORE_STATUS_MAP[status]; return fallback ?? { color: 'default', text: status }; } 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; watermark_template_id?: string | null; } /** * 店铺管理页,契约见 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 [watermarkTemplates, setWatermarkTemplates] = useState([]); const bindingBeforeRef = useRef(null); 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); } }; // 水印模板选项与平台无关(工作空间全量),挂载后拉取一次 useEffect(() => { void watermarksApi .list() .then((result) => setWatermarkTemplates(result.data)) .catch(() => setWatermarkTemplates([])); }, []); /** 任何变更后同时刷新列表与页头默认门店摘要 */ const refreshAll = useCallback(async () => { if (!platformId) return; await loadStores(platformId); await refreshContext(); }, [platformId, loadStores, refreshContext]); // 组件卸载后停止轮询 const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); /** * 轮询异步验证/同步操作至终态,随后刷新列表。 * 页面最终状态以刷新后的店铺资源为准(成败原因看店铺 last_error),不以 operation 为准; * 超时未终态则停止轮询并刷新一次,用户可手动刷新继续观察。 */ const pollOperation = useCallback( async (operationId: string) => { const intervalMs = 2500; const maxAttempts = 40; for (let attempt = 0; attempt < maxAttempts; attempt++) { await new Promise((resolve) => setTimeout(resolve, intervalMs)); if (!mountedRef.current) return; try { const operation = await operationsApi.get(operationId); if (OPERATION_TERMINAL_STATUSES.has(operation.status)) break; } catch { // 单次查询失败(网络抖动等)忽略,继续下一轮 } } if (mountedRef.current) await refreshAll(); }, [refreshAll] ); const openCreate = () => { createForm.resetFields(); createForm.setFieldsValue({ is_default: stores.length === 0, currency_code: currentPlatform?.default_currency_code ?? currencyOptions[0]?.value, }); setCreateOpen(true); }; const handleCreate = async (values: CreateFormValues) => { if (!currentPlatform) return; setCreating(true); try { const result = 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(); // 轮询验证操作至终态后自动刷新,无需手动刷新页面 if (result?.operation) void pollOperation(result.operation.id); } catch (err) { message.error(apiErrorMessage(err)); } finally { setCreating(false); } }; const openEdit = (store: Store) => { setEditingStore(store); bindingBeforeRef.current = store.watermark_template?.id ?? null; editForm.setFieldsValue({ name: store.name, currency_code: store.currency_code, note: store.note ?? '', watermark_template_id: store.watermark_template?.id ?? null, }); }; const handleEditSave = async (values: EditFormValues) => { if (!editingStore) return; const storeId = editingStore.id; const bindingBefore = bindingBeforeRef.current; const bindingAfter = values.watermark_template_id ?? null; const bindingChanged = bindingBefore !== bindingAfter; setSaving(true); try { const result = await storesApi.update( storeId, { 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 (bindingChanged) { await storesApi.bindWatermark(storeId, bindingAfter); } if (result && 'operation' in result && result.operation) { message.success('已保存,正在验证新密钥,结果将自动刷新'); setEditingStore(null); await refreshAll(); void pollOperation(result.operation.id); } 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 () => { const result = await storesApi.requestCredentialVerification(store.id); message.success('已发起凭据验证,结果将自动刷新'); if (result?.operation) void pollOperation(result.operation.id); }); const handleSyncWarehouses = (store: Store) => withPending(store, async () => { const result = await storesApi.requestWarehouseSync(store.id); message.success('已发起仓库同步,结果将自动刷新'); if (result?.operation) void pollOperation(result.operation.id); }); 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 view = deriveStoreStatusView(store); return ( {view.text} {view.note && ( {view.note} )} ); }, }, { 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) => ( ); } return (
} disabled={!currentPlatform} onClick={openCreate} > 添加店铺 } > {platforms.length === 0 && !currentPlatform ? ( ) : ( <> {nextCursor && (
)} )} setCreateOpen(false)} onOk={() => createForm.submit()} confirmLoading={creating} okText="创建并验证" cancelText="取消" destroyOnHidden maskClosable={false} > form={createForm} layout="vertical" onFinish={handleCreate}> {/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */} ({ value: template.id, label: template.name, }))} /> {editingStore.platform_client_id}(平台与账户不可修改) )} ); }; export default ShopsPage;