refactor(catalog): 废弃 LEWAN_ 镜像,统一英文 SKU 与积分价,商品图按 SKU 缓存

对外目录只保留英文主 sku;迁移硬删镜像并校准 1元=100积分;发货页/凭证优先使用 local {sku}.webp。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-14 20:00:26 +08:00
co-authored by Cursor
parent bf9614569e
commit df1a170d44
57 changed files with 1052 additions and 171 deletions
+2
View File
@@ -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: () =>
+20 -13
View File
@@ -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 || '游戏道具商品'}
+40 -4
View File
@@ -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} />
+22 -5
View File
@@ -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>
+72 -18
View File
@@ -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}
+47 -15
View File
@@ -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)}`
}
+4 -4
View File
@@ -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