refactor(catalog): 废弃 LEWAN_ 镜像,统一英文 SKU 与积分价,商品图按 SKU 缓存
对外目录只保留英文主 sku;迁移硬删镜像并校准 1元=100积分;发货页/凭证优先使用 local {sku}.webp。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -71,6 +71,8 @@ export const merchantApi = {
|
||||
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult<MerchantProduct>),
|
||||
updateProductStatus: (id: number, status: MerchantProduct['status']) =>
|
||||
request.patch(`/merchant/products/${id}/status`, { status }).then((r) => r.data.data),
|
||||
deleteProduct: (id: number) =>
|
||||
request.delete(`/merchant/products/${id}`).then((r) => r.data.data),
|
||||
orders: (params?: Record<string, unknown>) =>
|
||||
request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
|
||||
manualOrderProducts: () =>
|
||||
|
||||
@@ -358,19 +358,26 @@ export default function Delivery() {
|
||||
{/* 商品卡片信息 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 20, paddingBottom: 16, borderBottom: '1px solid #e2e8f0', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flex: 1, minWidth: 220 }}>
|
||||
{order.product?.image ? (
|
||||
<Image
|
||||
src={resolveProductImage(order.product.image)}
|
||||
width={72}
|
||||
height={72}
|
||||
alt={order.product.name}
|
||||
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 72, height: 72, background: '#e2e8f0', borderRadius: 8, display: 'grid', placeItems: 'center', color: '#64748b' }}>
|
||||
道具
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const imgSrc = resolveProductImage({ sku: order.product?.sku, url: order.product?.image })
|
||||
if (!imgSrc) {
|
||||
return (
|
||||
<div style={{ width: 72, height: 72, background: '#e2e8f0', borderRadius: 8, display: 'grid', placeItems: 'center', color: '#64748b' }}>
|
||||
道具
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
src={imgSrc}
|
||||
width={72}
|
||||
height={72}
|
||||
alt={order.product?.name || '商品'}
|
||||
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='72' height='72'%3E%3Crect fill='%23e2e8f0' width='72' height='72'/%3E%3C/svg%3E"
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: '#0f172a', marginBottom: 4 }}>
|
||||
{order.product?.name || '游戏道具商品'}
|
||||
|
||||
@@ -66,6 +66,10 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
const [orders, setOrders] = useState<PageResult<FulfillmentOrder>>({ list: [], total: 0, page: 1, size: 10 })
|
||||
const [orderSource, setOrderSource] = useState<'api' | 'manual'>('api')
|
||||
const [orderFilters, setOrderFilters] = useState<{ keyword: string; order_status: string }>({ keyword: '', order_status: '' })
|
||||
const [productFilters, setProductFilters] = useState<{ keyword: string; status: string }>({
|
||||
keyword: '',
|
||||
status: '',
|
||||
})
|
||||
const [ledger, setLedger] = useState<PageResult<WalletLedgerEntry>>({ list: [], total: 0, page: 1, size: 10 })
|
||||
const [wallet, setWallet] = useState<WalletAccount | null>(null)
|
||||
const [apiClients, setApiClients] = useState<ApiClient[]>([])
|
||||
@@ -159,10 +163,19 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
return data
|
||||
}, [])
|
||||
|
||||
const loadProducts = useCallback(async (page = products.page, size = products.size) => {
|
||||
const data = await merchantApi.products({ page, size })
|
||||
const loadProducts = useCallback(async (
|
||||
page = products.page,
|
||||
size = products.size,
|
||||
filters = productFilters,
|
||||
) => {
|
||||
const data = await merchantApi.products({
|
||||
page,
|
||||
size,
|
||||
keyword: filters.keyword || undefined,
|
||||
status: filters.status || undefined,
|
||||
})
|
||||
setProducts(data)
|
||||
}, [products.page, products.size])
|
||||
}, [productFilters, products.page, products.size])
|
||||
|
||||
const loadOrders = useCallback(async (
|
||||
page = orders.page,
|
||||
@@ -279,6 +292,20 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
.catch((e) => message.error(e instanceof Error ? e.message : '操作失败'))
|
||||
}
|
||||
|
||||
const deleteProduct = (record: MerchantProduct) => {
|
||||
merchantApi.deleteProduct(record.id)
|
||||
.then(() => {
|
||||
message.success('商品已删除')
|
||||
loadProducts()
|
||||
})
|
||||
.catch((e) => message.error(e instanceof Error ? e.message : '删除失败'))
|
||||
}
|
||||
|
||||
const changeProductFilters = (filters: { keyword: string; status: string }) => {
|
||||
setProductFilters(filters)
|
||||
loadProducts(1, products.size, filters).catch((e) => message.error(e instanceof Error ? e.message : '商品加载失败'))
|
||||
}
|
||||
|
||||
const handleWalletFilter = async () => {
|
||||
const values = await walletFilterForm.validateFields()
|
||||
loadWallet(1, ledger.size, merchantPermissions, {
|
||||
@@ -513,7 +540,16 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'products', label: '商品列表', disabled: !hasFeature('products'), children: (
|
||||
<ProductsTab loading={loading} products={products} canManage={canManage} onToggleProduct={openProductToggle} onLoadProducts={loadProducts} />
|
||||
<ProductsTab
|
||||
loading={loading}
|
||||
products={products}
|
||||
canManage={canManage}
|
||||
filters={productFilters}
|
||||
onToggleProduct={openProductToggle}
|
||||
onDeleteProduct={deleteProduct}
|
||||
onFilterChange={changeProductFilters}
|
||||
onLoadProducts={loadProducts}
|
||||
/>
|
||||
) },
|
||||
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
|
||||
<OrdersTab loading={loading} orders={orders} orderSource={orderSource} filters={orderFilters} canManage={canOrderManage} onCreateManualOrder={openManualOrderCreate} onOrderSourceChange={changeOrderSource} onFilterChange={changeOrderFilters} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} onCancelOrder={cancelOrder} />
|
||||
|
||||
@@ -227,7 +227,7 @@ export default function PlatformProducts() {
|
||||
lewan_product_id: lewanProduct.id,
|
||||
name: lewanProduct.name,
|
||||
category: lewanProduct.categoryName || '未分类',
|
||||
sku: `LEWAN_${lewanProduct.id.slice(0, 8).toUpperCase()}`,
|
||||
sku: '',
|
||||
price_yuan: unitPriceYuan,
|
||||
delivery_type: lewanProduct.deliveryType || 'LINK_BINDING',
|
||||
})
|
||||
@@ -269,7 +269,8 @@ export default function PlatformProducts() {
|
||||
content: (
|
||||
<div>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
将自动按乐玩供货价生成自营商品并配置为乐玩履约模式,同步加入平台自营目录。
|
||||
将按中文名关联到已有英文主商品并挂接乐玩履约;无同名主商品时请用单条导入并填写英文 sku。
|
||||
不再自动创建 LEWAN_ 镜像 sku。
|
||||
</Paragraph>
|
||||
<div
|
||||
style={{
|
||||
@@ -317,7 +318,8 @@ export default function PlatformProducts() {
|
||||
lewan_product_id: p.id,
|
||||
name: p.name,
|
||||
category: p.categoryName || '未分类',
|
||||
sku: `LEWAN_${p.id.slice(0, 8).toUpperCase()}`,
|
||||
// 留空:后端按中文名关联已有英文主商品;无匹配则跳过(见 BatchSync 错误汇总)
|
||||
sku: '',
|
||||
price: Number.isFinite(unitPriceFen) ? Math.round(unitPriceFen) : 1000,
|
||||
delivery_type: p.deliveryType || 'LINK_BINDING',
|
||||
}
|
||||
@@ -1045,8 +1047,23 @@ export default function PlatformProducts() {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="sku" label="自定义平台 SKU" rules={[{ required: true }]}>
|
||||
<Input placeholder="大写英文或拼音" />
|
||||
<Form.Item
|
||||
name="sku"
|
||||
label="英文主 SKU"
|
||||
extra="留空则按中文名关联已有商品;新建请填 suit_xxx / pack_xxx,勿用 LEWAN_ 前缀"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const sku = String(value || '').trim()
|
||||
if (!sku) return
|
||||
if (/^lewan_/i.test(sku)) {
|
||||
throw new Error('勿使用 LEWAN_ 前缀,请填写英文主 sku')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="留空=按名称关联;或 suit_xxx / pack_xxx" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -63,15 +63,29 @@ export function ProductsTab({
|
||||
loading,
|
||||
products,
|
||||
canManage,
|
||||
filters,
|
||||
onToggleProduct,
|
||||
onDeleteProduct,
|
||||
onFilterChange,
|
||||
onLoadProducts,
|
||||
}: {
|
||||
loading: boolean
|
||||
products: PageResult<MerchantProduct>
|
||||
canManage: boolean
|
||||
filters: { keyword: string; status: string }
|
||||
onToggleProduct: (record: MerchantProduct) => void
|
||||
onDeleteProduct: (record: MerchantProduct) => void
|
||||
onFilterChange: (filters: { keyword: string; status: string }) => void
|
||||
onLoadProducts: (page?: number, size?: number) => void
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState(filters.keyword)
|
||||
|
||||
useEffect(() => {
|
||||
setKeyword(filters.keyword)
|
||||
}, [filters.keyword])
|
||||
|
||||
const submitKeyword = () => onFilterChange({ ...filters, keyword: keyword.trim() })
|
||||
|
||||
const productColumns: ColumnsType<MerchantProduct> = [
|
||||
{
|
||||
title: '商户 SKU',
|
||||
@@ -128,19 +142,34 @@ export function ProductsTab({
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, record) => canManage ? (
|
||||
<Popconfirm
|
||||
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
|
||||
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
|
||||
okText="确认"
|
||||
onConfirm={() => onToggleProduct(record)}
|
||||
>
|
||||
<Button type="link" size="small">
|
||||
{record.status === 'active' ? '下架' : '上架'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Space size={0}>
|
||||
<Popconfirm
|
||||
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
|
||||
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
|
||||
okText="确认"
|
||||
onConfirm={() => onToggleProduct(record)}
|
||||
>
|
||||
<Button type="link" size="small">
|
||||
{record.status === 'active' ? '下架' : '上架'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{record.status === 'inactive' && (
|
||||
<Popconfirm
|
||||
title="删除该商品?"
|
||||
description="仅从商品列表移除(软删除),历史订单不受影响。删除后不可在此恢复。"
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => onDeleteProduct(record)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
) : '-',
|
||||
},
|
||||
]
|
||||
@@ -148,15 +177,40 @@ export function ProductsTab({
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}>商品上架与售卖管理</span>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
商户可自主上架/下架商品;下架后不可再创建该商品订单。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}>商品上架与售卖管理</span>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
可按状态与关键词筛选;已下架商品可删除。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="SKU / 商品名称"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={submitKeyword}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态"
|
||||
value={filters.status || undefined}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'active', label: '已上架' },
|
||||
{ value: 'inactive', label: '已下架' },
|
||||
]}
|
||||
onChange={(status) => onFilterChange({ ...filters, status: status || '' })}
|
||||
/>
|
||||
<Button onClick={() => {
|
||||
setKeyword('')
|
||||
onFilterChange({ keyword: '', status: '' })
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
/**
|
||||
* 解析商品图片地址:
|
||||
* 1. 本地相对路径(以 / 开头)直接返回;
|
||||
* 2. 乐玩/官方商品 UUID 地址,优先使用已下载至本地的图片缓存;
|
||||
* 3. 其他外链图片走后端持久化缓存代理 (/api/delivery/v1/image-proxy),杜绝跨域拦截与画布污染。
|
||||
* 商品图统一解析:优先英文 SKU 本地缓存。
|
||||
*
|
||||
* 约定目录:frontend/public/images/products/{sku}.webp
|
||||
* 同步:cd backend && go run ./cmd/sync_product_images
|
||||
*/
|
||||
export function resolveProductImage(url?: string | null): string {
|
||||
if (!url) return ''
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
return trimmed
|
||||
export type ProductImageInput = {
|
||||
sku?: string | null
|
||||
/** 上游缩略图 URL(乐玩/DLC);仅在无本地 SKU 图时作回退 */
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
function trim(value?: string | null): string {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
/** 按商户英文 SKU 取本地缓存图。 */
|
||||
export function resolveProductImageBySku(sku?: string | null): string {
|
||||
const normalized = trim(sku)
|
||||
if (!normalized) return ''
|
||||
return `/images/products/${normalized}.webp`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析展示用商品图:
|
||||
* 1. 有 sku → `/images/products/{sku}.webp`
|
||||
* 2. 否则从 url 提取乐玩 UUID → `/images/products/{uuid}.webp`(历史缓存)
|
||||
* 3. 其它外链 → image-proxy(运行时落盘到 data/uploads/products,仅兜底)
|
||||
*/
|
||||
export function resolveProductImage(input?: string | ProductImageInput | null): string {
|
||||
if (input == null) return ''
|
||||
|
||||
let sku = ''
|
||||
let url = ''
|
||||
if (typeof input === 'string') {
|
||||
url = trim(input)
|
||||
} else {
|
||||
sku = trim(input.sku)
|
||||
url = trim(input.url)
|
||||
}
|
||||
|
||||
// 匹配乐玩官方商品 ID:/products/<uuid>/...
|
||||
const uuidMatch = trimmed.match(/\/products\/([a-zA-Z0-9-]+)(?:\/|\.|$)/)
|
||||
if (uuidMatch && uuidMatch[1]) {
|
||||
if (sku) {
|
||||
return resolveProductImageBySku(sku)
|
||||
}
|
||||
|
||||
if (!url) return ''
|
||||
if (url.startsWith('/')) return url
|
||||
|
||||
const uuidMatch = url.match(/\/products\/([a-zA-Z0-9-]+)(?:\/|\.|$)/)
|
||||
if (uuidMatch?.[1]) {
|
||||
return `/images/products/${uuidMatch[1]}.webp`
|
||||
}
|
||||
|
||||
// 其它外部地址统一经由代理及本地磁盘缓存返回
|
||||
return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(trimmed)}`
|
||||
return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
@@ -89,12 +89,12 @@ export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise
|
||||
ctx.lineWidth = 1
|
||||
strokeRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16)
|
||||
|
||||
// 尝试加载商品图片(优先命中本地下载缓存)
|
||||
// 尝试加载商品图片(优先 SKU 本地缓存)
|
||||
let productImgLoaded = false
|
||||
if (order.product?.image) {
|
||||
const productImgURL = resolveProductImage({ sku: order.product?.sku, url: order.product?.image })
|
||||
if (productImgURL) {
|
||||
try {
|
||||
const imgURL = resolveProductImage(order.product.image)
|
||||
const img = await loadImage(imgURL)
|
||||
const img = await loadImage(productImgURL)
|
||||
const imgSize = 146
|
||||
const imgX = cardX + 64
|
||||
const imgY = prodCardY + 32
|
||||
|
||||
Reference in New Issue
Block a user