feat(web-water): 水印设置页面开发
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
App as AntdApp,
|
||||
Button,
|
||||
@@ -24,12 +24,13 @@ import {
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
WarningFilled,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import type { Store } from '@atlas/contracts';
|
||||
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';
|
||||
|
||||
@@ -42,20 +43,62 @@ const STORE_STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
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: '待轮换' },
|
||||
};
|
||||
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 });
|
||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
interface CreateFormValues {
|
||||
@@ -72,6 +115,7 @@ interface EditFormValues {
|
||||
currency_code: string;
|
||||
note?: string;
|
||||
api_key?: string;
|
||||
watermark_template_id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +147,10 @@ const ShopsPage = () => {
|
||||
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(
|
||||
() =>
|
||||
@@ -148,6 +196,14 @@ const ShopsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 水印模板选项与平台无关(工作空间全量),挂载后拉取一次
|
||||
useEffect(() => {
|
||||
void watermarksApi
|
||||
.list()
|
||||
.then((result) => setWatermarkTemplates(result.data))
|
||||
.catch(() => setWatermarkTemplates([]));
|
||||
}, []);
|
||||
|
||||
/** 任何变更后同时刷新列表与页头默认门店摘要 */
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (!platformId) return;
|
||||
@@ -155,12 +211,44 @@ const ShopsPage = () => {
|
||||
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,
|
||||
currency_code: currentPlatform?.default_currency_code ?? currencyOptions[0]?.value,
|
||||
});
|
||||
setCreateOpen(true);
|
||||
};
|
||||
@@ -169,7 +257,7 @@ const ShopsPage = () => {
|
||||
if (!currentPlatform) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await storesApi.create({
|
||||
const result = await storesApi.create({
|
||||
platform_id: currentPlatform.id,
|
||||
name: values.name.trim(),
|
||||
client_id: values.client_id.trim(),
|
||||
@@ -178,9 +266,11 @@ const ShopsPage = () => {
|
||||
is_default: values.is_default,
|
||||
note: values.note?.trim() ? values.note.trim() : null,
|
||||
});
|
||||
message.success('店铺已创建,凭据验证进行中,稍后可刷新查看结果');
|
||||
message.success('店铺已创建,正在验证凭据,结果将自动刷新');
|
||||
setCreateOpen(false);
|
||||
await refreshAll();
|
||||
// 轮询验证操作至终态后自动刷新,无需手动刷新页面
|
||||
if (result?.operation) void pollOperation(result.operation.id);
|
||||
} catch (err) {
|
||||
message.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
@@ -190,19 +280,25 @@ const ShopsPage = () => {
|
||||
|
||||
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(
|
||||
editingStore.id,
|
||||
storeId,
|
||||
{
|
||||
name: values.name.trim(),
|
||||
currency_code: values.currency_code,
|
||||
@@ -212,13 +308,20 @@ const ShopsPage = () => {
|
||||
},
|
||||
{ revision: editingStore.revision }
|
||||
);
|
||||
if (result && 'operation' in result) {
|
||||
message.success('已保存,新密钥验证进行中,稍后可刷新查看结果');
|
||||
// 水印绑定是独立接口,仅在与打开弹窗时不同才提交
|
||||
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();
|
||||
}
|
||||
setEditingStore(null);
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
// 乐观锁版本过期:提示后刷新列表,用户重新编辑拿到新 revision
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
@@ -253,14 +356,16 @@ const ShopsPage = () => {
|
||||
|
||||
const handleVerify = (store: Store) =>
|
||||
withPending(store, async () => {
|
||||
await storesApi.requestCredentialVerification(store.id);
|
||||
message.success('已发起凭据验证,稍后可刷新查看结果');
|
||||
const result = await storesApi.requestCredentialVerification(store.id);
|
||||
message.success('已发起凭据验证,结果将自动刷新');
|
||||
if (result?.operation) void pollOperation(result.operation.id);
|
||||
});
|
||||
|
||||
const handleSyncWarehouses = (store: Store) =>
|
||||
withPending(store, async () => {
|
||||
await storesApi.requestWarehouseSync(store.id);
|
||||
message.success('已发起仓库同步,稍后可刷新查看结果');
|
||||
const result = await storesApi.requestWarehouseSync(store.id);
|
||||
message.success('已发起仓库同步,结果将自动刷新');
|
||||
if (result?.operation) void pollOperation(result.operation.id);
|
||||
});
|
||||
|
||||
const handleDelete = (store: Store) => {
|
||||
@@ -340,26 +445,18 @@ const ShopsPage = () => {
|
||||
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,
|
||||
};
|
||||
const view = deriveStoreStatusView(store);
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
<Tag color={status.color}>{status.text}</Tag>
|
||||
{store.status !== 'verifying' && (
|
||||
<Tag color={credential.color}>{credential.text}</Tag>
|
||||
)}
|
||||
{store.last_error && (
|
||||
<Tooltip
|
||||
title={`${store.last_error.code}:${store.last_error.message}`}
|
||||
<Tag color={view.color}>{view.text}</Tag>
|
||||
{view.note && (
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, maxWidth: 180 }}
|
||||
ellipsis={{ tooltip: view.note }}
|
||||
>
|
||||
<WarningFilled style={{ color: '#faad14' }} />
|
||||
</Tooltip>
|
||||
{view.note}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
@@ -408,9 +505,7 @@ const ShopsPage = () => {
|
||||
dataIndex: 'updated_at',
|
||||
width: 170,
|
||||
render: (value: string) => (
|
||||
<Typography.Text type="secondary">
|
||||
{formatDateTime(value)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">{formatDateTime(value)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -503,11 +598,7 @@ const ShopsPage = () => {
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form<CreateFormValues>
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
onFinish={handleCreate}
|
||||
>
|
||||
<Form<CreateFormValues> form={createForm} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="店铺名称"
|
||||
@@ -520,7 +611,9 @@ const ShopsPage = () => {
|
||||
name="client_id"
|
||||
label="Client ID"
|
||||
required
|
||||
rules={[{ required: true, whitespace: true, message: '请输入平台 Client ID' }]}
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: '请输入平台 Client ID' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={120} placeholder="平台后台的 Client ID" />
|
||||
</Form.Item>
|
||||
@@ -528,7 +621,9 @@ const ShopsPage = () => {
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
required
|
||||
rules={[{ required: true, whitespace: true, message: '请输入平台 API Key' }]}
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: '请输入平台 API Key' },
|
||||
]}
|
||||
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
|
||||
>
|
||||
{/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */}
|
||||
@@ -546,11 +641,7 @@ const ShopsPage = () => {
|
||||
>
|
||||
<Select options={currencyOptions} placeholder="选择币种" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="is_default"
|
||||
label="设为默认店铺"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Form.Item name="is_default" label="设为默认店铺" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -614,6 +705,21 @@ const ShopsPage = () => {
|
||||
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}(平台与账户不可修改)
|
||||
|
||||
Reference in New Issue
Block a user