Files
atlas-seller-kit/apps/web/src/pages/shops/ShopsPage.tsx
T
joey5628 46d40b184b feat(web): 店铺必填项拦截纯空格输入,API Key 放宽到契约上限
- 店铺名称/Client ID/API Key 的 required 规则加 whitespace: true,
  与服务端 trim 后非空校验对齐
- API Key 输入 maxLength 200 → 8192(packages/contracts 契约上限),
  避免误拦较长的真实平台密钥
- 浏览器实测:空提交与纯空格提交均展示四条必填错误且不发请求,
  正常填写创建成功
2026-09-03 22:00:33 +08:00

626 lines
23 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, 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 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, 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"
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={8192}
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;