Compare commits
15 Commits
3ab999d0b2
...
66ca8b496c
| Author | SHA1 | Date | |
|---|---|---|---|
| 66ca8b496c | |||
| 5bdf1645d2 | |||
| 1b0cdc33aa | |||
| d199c4d6df | |||
| aa3076c434 | |||
| eecec5e0d0 | |||
| c0473d607a | |||
| 27b9e07b62 | |||
| 6879e7a8b7 | |||
| 9164fc962e | |||
| 24a25dcbd1 | |||
| 5bf5f80b34 | |||
| 46d40b184b | |||
| 446baf5939 | |||
| 8655b7805f |
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -39,7 +40,11 @@ type HttpResponse = { status: (status: number) => HttpResponse };
|
|||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class PlatformsController {
|
export class PlatformsController {
|
||||||
public constructor(private readonly platformsService: PlatformsService) {}
|
// tsx/esbuild 不生成 design:paramtypes 元数据,控制器依赖必须显式 @Inject(与 auth.controller 一致)
|
||||||
|
public constructor(
|
||||||
|
@Inject(PlatformsService)
|
||||||
|
private readonly platformsService: PlatformsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('platforms')
|
@Get('platforms')
|
||||||
@ApiOperation({ summary: '获取已启用的平台及非敏感能力目录' })
|
@ApiOperation({ summary: '获取已启用的平台及非敏感能力目录' })
|
||||||
@@ -75,7 +80,9 @@ export class PlatformsController {
|
|||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@Controller('stores')
|
@Controller('stores')
|
||||||
export class StoresController {
|
export class StoresController {
|
||||||
public constructor(private readonly storesService: StoresService) {}
|
public constructor(
|
||||||
|
@Inject(StoresService) private readonly storesService: StoresService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '按平台分页列出当前工作空间的门店' })
|
@ApiOperation({ summary: '按平台分页列出当前工作空间的门店' })
|
||||||
@@ -260,7 +267,9 @@ export class StoresController {
|
|||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@Controller('operations')
|
@Controller('operations')
|
||||||
export class OperationsController {
|
export class OperationsController {
|
||||||
public constructor(private readonly storesService: StoresService) {}
|
public constructor(
|
||||||
|
@Inject(StoresService) private readonly storesService: StoresService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get(':operationId')
|
@Get(':operationId')
|
||||||
@ApiOperation({ summary: '读取当前工作空间的异步操作状态' })
|
@ApiOperation({ summary: '读取当前工作空间的异步操作状态' })
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
.settings-page {
|
.settings-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card {
|
.settings-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 720px;
|
max-width: 560px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
|
/* 四周柔和阴影,与内容区背景拉开层次 */
|
||||||
|
box-shadow: 0 0 24px 0 rgba(17, 24, 39, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-title {
|
.settings-card-title {
|
||||||
|
|||||||
+10
-2
@@ -12,12 +12,20 @@ function App() {
|
|||||||
theme={{
|
theme={{
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: '#8b5cf6',
|
colorPrimary: '#8b5cf6',
|
||||||
borderRadius: 8,
|
// 全局圆角 10px,输入框/按钮/卡片等同步生效
|
||||||
// 全局默认字号 14px;输入类控件默认高度在 32px 基础上同步加高一点
|
borderRadius: 10,
|
||||||
|
// 全局正文字号 14px;输入类控件经 components 单独放大
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
controlHeight: 34,
|
controlHeight: 34,
|
||||||
controlHeightSM: 26,
|
controlHeightSM: 26,
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
// 文本框:文本 16px、高度同步加高;按钮与表格/菜单等保持全局默认
|
||||||
|
Input: { fontSize: 16, controlHeight: 40 },
|
||||||
|
Select: { fontSize: 16, controlHeight: 40 },
|
||||||
|
InputNumber: { fontSize: 16, controlHeight: 40 },
|
||||||
|
DatePicker: { fontSize: 16, controlHeight: 40 },
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AntdApp>
|
<AntdApp>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
.main-header {
|
.main-header {
|
||||||
background: white !important;
|
background: white !important;
|
||||||
padding: 0 24px !important;
|
padding: 0 20px 0 10px !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -102,6 +102,25 @@
|
|||||||
gap: 12px;
|
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 有浅色背景反馈,暗示可下拉 */
|
/* 头像 + 用户名触发区:hover 有浅色背景反馈,暗示可下拉 */
|
||||||
.header-user-trigger {
|
.header-user-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -152,9 +171,26 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
/* 与 collapse-trigger 一致的 28px 盒子,内部垂直居中 */
|
||||||
|
min-height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 条目与分隔图标按垂直中心对齐,避免不同字号图标基线错位 */
|
||||||
|
.page-breadcrumb ol {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-breadcrumb li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-breadcrumb .ant-breadcrumb-link {
|
.page-breadcrumb .ant-breadcrumb-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
|
RightOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import SidebarMenu from './SidebarMenu';
|
import SidebarMenu from './SidebarMenu';
|
||||||
|
import PlatformSelector from './PlatformSelector';
|
||||||
import { getBreadcrumbItems } from './menuConfig';
|
import { getBreadcrumbItems } from './menuConfig';
|
||||||
import { useSession } from '@/services/session';
|
import { useSession } from '@/services/session';
|
||||||
import './MainLayout.css';
|
import './MainLayout.css';
|
||||||
@@ -150,12 +152,13 @@ const MainLayout = () => {
|
|||||||
)}
|
)}
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
className="page-breadcrumb"
|
className="page-breadcrumb"
|
||||||
separator=">"
|
separator={<RightOutlined style={{ fontSize: 12 }} />}
|
||||||
items={breadcrumbItems}
|
items={breadcrumbItems}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{session && (
|
{session && (
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
|
<PlatformSelector />
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
|
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
|
||||||
trigger={['hover']}
|
trigger={['hover']}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { GlobalOutlined } from '@ant-design/icons';
|
||||||
|
import { Select, Tooltip } from 'antd';
|
||||||
|
import { usePlatform } from '@/services/platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页头平台选择器:采集编辑、发布等动作按页头当前平台取得发布配置与默认店铺
|
||||||
|
* (docs/requirements/collector-browser-extension.md 的页头平台上下文约定)。
|
||||||
|
* 首次使用默认选中目录第一家(Ozon 优先),选择结果仅作为非敏感 UI 偏好本地记忆。
|
||||||
|
*/
|
||||||
|
const PlatformSelector = () => {
|
||||||
|
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 (
|
||||||
|
<Tooltip title={tooltip}>
|
||||||
|
<div className="platform-selector">
|
||||||
|
<GlobalOutlined className="platform-selector-icon" />
|
||||||
|
<Select
|
||||||
|
variant="borderless"
|
||||||
|
loading={contextLoading && !currentPlatform}
|
||||||
|
value={currentPlatform?.id}
|
||||||
|
onChange={selectPlatform}
|
||||||
|
options={platforms.map((platform) => ({
|
||||||
|
value: platform.id,
|
||||||
|
label: platform.name,
|
||||||
|
}))}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
aria-label="选择当前平台"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlatformSelector;
|
||||||
@@ -64,7 +64,6 @@ const PasswordSettingsPage = () => {
|
|||||||
<Form<PasswordFormValues>
|
<Form<PasswordFormValues>
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
requiredMark={false}
|
|
||||||
onFinish={handleFinish}
|
onFinish={handleFinish}
|
||||||
className="profile-form"
|
className="profile-form"
|
||||||
>
|
>
|
||||||
@@ -117,6 +116,7 @@ const PasswordSettingsPage = () => {
|
|||||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||||
保存修改
|
保存修改
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => navigate('/')}>取消</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { App as AntdApp, Avatar, Button, Form, Input } from 'antd';
|
import { App as AntdApp, Avatar, Button, Form, Input } from 'antd';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { authApi } from '@/services/auth';
|
import { authApi } from '@/services/auth';
|
||||||
@@ -15,6 +16,7 @@ interface ProfileFormValues {
|
|||||||
*/
|
*/
|
||||||
const ProfileSettingsPage = () => {
|
const ProfileSettingsPage = () => {
|
||||||
const { message } = AntdApp.useApp();
|
const { message } = AntdApp.useApp();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { session, applyProfileUpdate } = useSession();
|
const { session, applyProfileUpdate } = useSession();
|
||||||
const [form] = Form.useForm<ProfileFormValues>();
|
const [form] = Form.useForm<ProfileFormValues>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -78,7 +80,6 @@ const ProfileSettingsPage = () => {
|
|||||||
<Form<ProfileFormValues>
|
<Form<ProfileFormValues>
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
requiredMark={false}
|
|
||||||
initialValues={{ display_name: session.user.display_name ?? '' }}
|
initialValues={{ display_name: session.user.display_name ?? '' }}
|
||||||
onFinish={handleSave}
|
onFinish={handleSave}
|
||||||
className="profile-form"
|
className="profile-form"
|
||||||
@@ -101,6 +102,7 @@ const ProfileSettingsPage = () => {
|
|||||||
保存修改
|
保存修改
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleReset}>重 置</Button>
|
<Button onClick={handleReset}>重 置</Button>
|
||||||
|
<Button onClick={() => navigate('/')}>取消</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.shops-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shops-load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
@@ -1,8 +1,623 @@
|
|||||||
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 STORE_STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||||
const ShopsPage = () => (
|
verifying: { color: 'gold', text: '验证中' },
|
||||||
<PagePlaceholder title="店铺管理" description="绑定平台店铺授权与水印,模块开发中。" />
|
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"
|
||||||
|
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 label="Client ID">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{editingStore.platform_client_id}(平台与账户不可修改)
|
||||||
|
</Typography.Text>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ShopsPage;
|
export default ShopsPage;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
|
|||||||
import RegisterPage from '@/pages/auth/RegisterPage';
|
import RegisterPage from '@/pages/auth/RegisterPage';
|
||||||
import ShopsPage from '@/pages/shops/ShopsPage';
|
import ShopsPage from '@/pages/shops/ShopsPage';
|
||||||
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
||||||
|
import { PlatformProvider } from '@/services/platform';
|
||||||
import { useSession } from '@/services/session';
|
import { useSession } from '@/services/session';
|
||||||
|
|
||||||
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
|
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
|
||||||
@@ -45,7 +46,10 @@ export const router = createBrowserRouter([
|
|||||||
path: '/',
|
path: '/',
|
||||||
element: (
|
element: (
|
||||||
<RequireSession>
|
<RequireSession>
|
||||||
|
{/* 平台上下文仅在登录后可用,随主布局挂载 */}
|
||||||
|
<PlatformProvider>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
|
</PlatformProvider>
|
||||||
</RequireSession>
|
</RequireSession>
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ apiClient.interceptors.response.use(
|
|||||||
/**
|
/**
|
||||||
* 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体
|
* 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体
|
||||||
* (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身;
|
* (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身;
|
||||||
* 未来列表接口({ data, meta })在此扩展专门的解包方法以保留 meta。
|
* 列表接口使用 getList 保留分页 meta。
|
||||||
*/
|
*/
|
||||||
function unwrapEnvelope<T>(response: AxiosResponse): T {
|
function unwrapEnvelope<T>(response: AxiosResponse): T {
|
||||||
const body = response.data as { data?: unknown } | undefined;
|
const body = response.data as { data?: unknown } | undefined;
|
||||||
@@ -91,10 +91,22 @@ function unwrapEnvelope<T>(response: AxiosResponse): T {
|
|||||||
return body.data as T;
|
return body.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 列表响应信封为 { data: T[], meta: PaginationMeta }(docs/api/conventions.md §3),meta 需要原样保留 */
|
||||||
|
function unwrapListEnvelope<T, M>(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 = {
|
export const api = {
|
||||||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||||
apiClient.get<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
apiClient.get<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||||||
|
|
||||||
|
getList: <T = unknown, M = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||||
|
apiClient.get(url, config).then((res) => unwrapListEnvelope<T, M>(res)),
|
||||||
|
|
||||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||||
|
|
||||||
@@ -118,6 +130,14 @@ export function apiErrorMessage(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
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 {
|
export interface ApiErrorDetail {
|
||||||
field: string;
|
field: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
/** 目录加载失败后重试 */
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlatformContextContext = createContext<PlatformContextValue | null>(null);
|
||||||
|
|
||||||
|
/** 平台上下文:页头平台选择器的数据源,门店等模块据此确定当前平台(目录接口将 Ozon 排在最前) */
|
||||||
|
export function PlatformProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(() =>
|
||||||
|
localStorage.getItem(PLATFORM_STORAGE_KEY)
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [context, setContext] = useState<PlatformContext | null>(null);
|
||||||
|
const [contextLoading, setContextLoading] = useState(false);
|
||||||
|
// 快速切换平台时丢弃过期响应与 loading:只接受最后一次请求的结果
|
||||||
|
const contextRequestIdRef = useRef(0);
|
||||||
|
|
||||||
|
const loadPlatforms = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const list = await api.get<Platform[]>('/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<PlatformContext>('/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 (
|
||||||
|
<PlatformContextContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</PlatformContextContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlatform(): PlatformContextValue {
|
||||||
|
const context = useContext(PlatformContextContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('usePlatform 必须在 PlatformProvider 内使用');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -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<Store, CursorPaginationMeta>('/stores', {
|
||||||
|
params: { platform_id: platformId, limit: 20, ...(cursor ? { cursor } : {}) },
|
||||||
|
}),
|
||||||
|
|
||||||
|
get: (storeId: string) => api.get<Store>(`/stores/${storeId}`),
|
||||||
|
|
||||||
|
/** 创建后店铺处于 verifying 状态,凭据验证由后台异步执行 */
|
||||||
|
create: (payload: CreateStoreRequest) =>
|
||||||
|
api.post<StoreOperationResponse>('/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<Store | StoreOperationResponse>(`/stores/${storeId}`, payload, {
|
||||||
|
headers: {
|
||||||
|
'If-Match': `"${options.revision}"`,
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
setDefault: (storeId: string) =>
|
||||||
|
api.post<Store>(`/stores/${storeId}/set-default`, undefined, {
|
||||||
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}),
|
||||||
|
|
||||||
|
requestCredentialVerification: (storeId: string) =>
|
||||||
|
api.post<StoreOperationResponse>(`/stores/${storeId}/credential-verifications`, undefined, {
|
||||||
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}),
|
||||||
|
|
||||||
|
requestWarehouseSync: (storeId: string) =>
|
||||||
|
api.post<StoreOperationResponse>(`/stores/${storeId}/warehouse-syncs`, undefined, {
|
||||||
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** 删除当前默认店铺时同平台还有其他店铺会返回 422 DEFAULT_STORE_REPLACEMENT_REQUIRED */
|
||||||
|
remove: (storeId: string) =>
|
||||||
|
api.delete<void>(`/stores/${storeId}`, {
|
||||||
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}),
|
||||||
|
};
|
||||||
+13
-2
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
这是一个全新的云端产品,不是 `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)。
|
当前运行时基线: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。
|
- 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`。
|
- 扩展: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. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
| 主题 | 决策 |
|
| 主题 | 决策 |
|
||||||
@@ -98,7 +109,7 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
1. 实现 Outbox Dispatcher 与 Worker 的首个消费链路:将 `store_credential_verification` 投递到 Worker,解密密钥后调用平台适配器,回写门店/密钥状态;失败信息必须脱敏。
|
1. 实现 Outbox Dispatcher 与 Worker 的首个消费链路:将 `store_credential_verification` 投递到 Worker,解密密钥后调用平台适配器,回写门店/密钥状态;失败信息必须脱敏。
|
||||||
2. 在凭据验证通过后实现仓库同步 Worker、幂等 upsert 和状态回写,并补充失败重试、死信与集成测试。
|
2. 在凭据验证通过后实现仓库同步 Worker、幂等 upsert 和状态回写,并补充失败重试、死信与集成测试。
|
||||||
3. Web 接入真实认证和门店管理 API:登录/注册、会话刷新、平台上下文、门店列表/创建/编辑/默认切换;前端不得保存 API Key 或 refresh token。
|
3. ~~Web 接入真实认证和门店管理 API~~ 已基本完成:登录/注册、会话刷新、平台上下文、门店列表/创建/编辑/默认切换/删除均已上线(见 1.2);剩余水印绑定入口待素材/水印模块交付后接入。前端不得保存 API Key 或 refresh token。
|
||||||
4. 接着实现素材直传和水印管理,再开始采集箱与商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
4. 接着实现素材直传和水印管理,再开始采集箱与商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。
|
||||||
5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。
|
||||||
|
|
||||||
|
|||||||
@@ -75,3 +75,37 @@ export type Operation = {
|
|||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
error: { code: string; message: 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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user