Files
atlas-seller-kit/apps/web/src/pages/shops/ShopsPage.tsx
T
2026-09-04 15:57:16 +08:00

736 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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: '已删除' },
};
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<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 [watermarkTemplates, setWatermarkTemplates] = useState<WatermarkTemplate[]>([]);
const bindingBeforeRef = useRef<string | null>(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<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 () => {
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: <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 view = deriveStoreStatusView(store);
return (
<Space size={4} wrap>
<Tag color={view.color}>{view.text}</Tag>
{view.note && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, maxWidth: 180 }}
ellipsis={{ tooltip: view.note }}
>
{view.note}
</Typography.Text>
)}
</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" onFinish={handleCreate}>
<Form.Item
name="name"
label="店铺名称"
required
rules={[{ required: true, whitespace: true, message: '请输入店铺名称' }]}
>
<Input maxLength={120} placeholder="例如:Ozon 俄罗斯主店" />
</Form.Item>
<Form.Item
name="client_id"
label="Client ID"
required
rules={[
{ required: true, whitespace: true, message: '请输入平台 Client ID' },
]}
>
<Input maxLength={120} placeholder="平台后台的 Client ID" />
</Form.Item>
<Form.Item
name="api_key"
label="API Key"
required
rules={[
{ required: true, whitespace: true, message: '请输入平台 API Key' },
]}
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
>
{/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */}
<Input.Password
maxLength={8192}
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"
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={8192}
placeholder="不修改请留空"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="watermark_template_id"
label="水印模板"
extra="作为商品图片编辑时的推荐水印;可随时更换或清空"
>
<Select
allowClear
placeholder="暂不绑定"
notFoundContent="暂无水印模板,可在水印管理页创建"
options={watermarkTemplates.map((template) => ({
value: template.id,
label: template.name,
}))}
/>
</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;