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
+11
View File
@@ -0,0 +1,11 @@
.shops-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.shops-load-more {
display: flex;
justify-content: center;
margin-top: 16px;
}
+621 -5
View File
@@ -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 = () => (
<PagePlaceholder title="店铺管理" description="绑定平台店铺授权与水印,模块开发中。" />
);
const STORE_STATUS_MAP: Record<string, { color: string; text: string }> = {
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<string, { color: string; text: string }> = {
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<Store[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [pendingStoreId, setPendingStoreId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<CreateFormValues>();
const [editingStore, setEditingStore] = useState<Store | null>(null);
const [saving, setSaving] = useState(false);
const [editForm] = Form.useForm<EditFormValues>();
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<unknown>) => {
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: <ApiOutlined />,
label: '设为默认店铺',
disabled: store.is_default,
},
{ key: 'verify', icon: <ReloadOutlined />, label: '重新验证凭据' },
{ key: 'sync', icon: <CloudDownloadOutlined />, label: '同步仓库' },
{ type: 'divider' },
{ key: 'delete', icon: <DeleteOutlined />, 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<Store>['columns'] = [
{
title: '店铺名称',
dataIndex: 'name',
width: 220,
render: (_, store) => (
<Space size={6}>
<span>{store.name}</span>
{store.is_default && <Tag color="purple">默认</Tag>}
</Space>
),
},
{
title: 'Client ID',
dataIndex: 'platform_client_id',
width: 150,
render: (value: string) => (
<Typography.Text copyable={{ text: value }}>{value}</Typography.Text>
),
},
{
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 (
<Space size={4} wrap>
<Tag color={status.color}>{status.text}</Tag>
<Tag color={credential.color}>{credential.text}</Tag>
{store.last_error && (
<Tooltip
title={`${store.last_error.code}:${store.last_error.message}`}
>
<WarningFilled style={{ color: '#faad14' }} />
</Tooltip>
)}
</Space>
);
},
},
{
title: '仓库',
dataIndex: 'warehouse_count',
width: 90,
render: (count: number, store) => (
<Tooltip
title={
store.warehouses_synced_at
? `同步于 ${formatDateTime(store.warehouses_synced_at)}`
: '尚未同步仓库'
}
>
<span>{store.warehouses_synced_at ? count : '—'}</span>
</Tooltip>
),
},
{
title: '水印模板',
dataIndex: 'watermark_template',
width: 130,
render: (template: Store['watermark_template']) =>
template ? (
template.name
) : (
<Typography.Text type="secondary">未绑定</Typography.Text>
),
},
{
title: '备注',
dataIndex: 'note',
ellipsis: true,
render: (note: string | null) =>
note ? (
<Typography.Text ellipsis>{note}</Typography.Text>
) : (
<Typography.Text type="secondary">—</Typography.Text>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: (value: string) => (
<Typography.Text type="secondary">
{formatDateTime(value)}
</Typography.Text>
),
},
{
title: '操作',
key: 'actions',
width: 140,
fixed: 'right',
render: (_, store) => (
<Space size={4}>
<Button type="link" size="small" onClick={() => openEdit(store)}>
编辑
</Button>
<Dropdown
menu={{ items: rowMenu(store), onClick: handleRowMenuClick(store) }}
trigger={['click']}
>
<Button
type="text"
size="small"
icon={<MoreOutlined />}
loading={pendingStoreId === store.id}
/>
</Dropdown>
</Space>
),
},
];
if (platformError) {
return (
<Card className="shops-page">
<Empty description={`平台目录加载失败:${platformError}`}>
<Button onClick={reload}>重试</Button>
</Empty>
</Card>
);
}
return (
<div className="shops-page">
<Card
title={`店铺管理${currentPlatform ? ` · ${currentPlatform.name}` : ''}`}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!currentPlatform}
onClick={openCreate}
>
添加店铺
</Button>
}
>
{platforms.length === 0 && !currentPlatform ? (
<Empty description="暂无可用平台" />
) : (
<>
<Table
rowKey="id"
columns={columns}
dataSource={stores}
loading={loading}
pagination={false}
scroll={{ x: 1100 }}
locale={{
emptyText: currentPlatform
? `当前平台还没有店铺,点击右上角「添加店铺」完成 ${currentPlatform.name} 授权`
: '请先在页头选择平台',
}}
/>
{nextCursor && (
<div className="shops-load-more">
<Button loading={loadingMore} onClick={() => void loadMore()}>
加载更多
</Button>
</div>
)}
</>
)}
</Card>
<Modal
title={`添加${currentPlatform?.name ?? ''}店铺`}
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
confirmLoading={creating}
okText="创建并验证"
cancelText="取消"
destroyOnHidden
maskClosable={false}
>
<Form<CreateFormValues>
form={createForm}
layout="vertical"
requiredMark={false}
onFinish={handleCreate}
>
<Form.Item
name="name"
label="店铺名称"
required
rules={[{ required: true, message: '请输入店铺名称' }]}
>
<Input maxLength={120} placeholder="例如:Ozon 俄罗斯主店" />
</Form.Item>
<Form.Item
name="client_id"
label="Client ID"
required
rules={[{ required: true, message: '请输入平台 Client ID' }]}
>
<Input maxLength={120} placeholder="平台后台的 Client ID" />
</Form.Item>
<Form.Item
name="api_key"
label="API Key"
required
rules={[{ required: true, message: '请输入平台 API Key' }]}
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
>
<Input.Password
maxLength={200}
placeholder="平台后台的 API Key"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="currency_code"
label="默认计价币种"
required
rules={[{ required: true, message: '请选择币种' }]}
>
<Select options={currencyOptions} placeholder="选择币种" />
</Form.Item>
<Form.Item
name="is_default"
label="设为默认店铺"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name="note"
label="备注"
rules={[{ max: 2000, message: '备注不能超过 2000 字' }]}
>
<Input.TextArea rows={2} maxLength={2000} placeholder="选填" />
</Form.Item>
</Form>
</Modal>
<Modal
title={editingStore ? `编辑店铺:${editingStore.name}` : '编辑店铺'}
open={!!editingStore}
onCancel={() => setEditingStore(null)}
onOk={() => editForm.submit()}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnHidden
maskClosable={false}
>
{editingStore && (
<Form<EditFormValues>
form={editForm}
layout="vertical"
requiredMark={false}
onFinish={handleEditSave}
>
<Form.Item
name="name"
label="店铺名称"
required
rules={[{ required: true, message: '请输入店铺名称' }]}
>
<Input maxLength={120} />
</Form.Item>
<Form.Item
name="currency_code"
label="默认计价币种"
required
rules={[{ required: true, message: '请选择币种' }]}
>
<Select options={currencyOptions} />
</Form.Item>
<Form.Item
name="note"
label="备注"
rules={[{ max: 2000, message: '备注不能超过 2000 字' }]}
>
<Input.TextArea rows={2} maxLength={2000} placeholder="选填" />
</Form.Item>
<Form.Item
name="api_key"
label="替换 API Key"
extra="留空保留现有密钥;填写后将替换并重新验证凭据"
>
<Input.Password
maxLength={200}
placeholder="不修改请留空"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item label="Client ID">
<Typography.Text type="secondary">
{editingStore.platform_client_id}(平台与账户不可修改)
</Typography.Text>
</Form.Item>
</Form>
)}
</Modal>
</div>
);
};
export default ShopsPage;