feat: start ecommerce product center

This commit is contained in:
QiuSW
2026-07-09 09:10:22 +08:00
parent b8323550da
commit bda9c57843
11 changed files with 448 additions and 8 deletions
@@ -9,9 +9,9 @@ const metrics = [
]
const recentItems = [
{ title: '商品图片 AI 优化', status: '目标业务样例', time: 'T-020' },
{ title: '审计日志', status: '目标模块', time: 'T-021' },
{ title: 'P0 数据统计', status: '目标模块', time: 'T-022' },
{ title: '商品中心', status: 'EC 分支起点', time: 'EC-001' },
{ title: '商品图片 AI 优化', status: '已接入', time: 'T-023' },
{ title: 'P0 数据统计', status: '后端已接入', time: 'T-022' },
]
</script>
@@ -0,0 +1,354 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import {
PlusOutlined,
ReloadOutlined,
SearchOutlined,
} from '@ant-design/icons-vue'
import ModulePageShell from '../../components/template/ModulePageShell.vue'
type PublishStatus = 'draft' | 'reviewing' | 'online' | 'offline'
type ImageStatus = 'pending' | 'optimized' | 'failed'
type ProductRow = {
id: number
sku: string
name: string
category: string
channel: string
price: number
stock: number
publishStatus: PublishStatus
imageStatus: ImageStatus
updatedAt: string
}
const categoryOptions = ['服饰', '美妆', '家居', '数码', '食品']
const channelOptions = ['淘宝', '京东', '抖音', '小红书', '独立站']
const draftName = ref('夏季防晒衬衫')
const draftSku = ref('EC-SHIRT-001')
const draftCategory = ref('服饰')
const draftChannel = ref('淘宝')
const draftPrice = ref(129)
const draftStock = ref(80)
const keyword = ref('')
const categoryFilter = ref('')
const imageFilter = ref('')
const publishFilter = ref('')
const currentPage = ref(1)
const pageSize = 6
const products = ref<ProductRow[]>([
{ id: 1, sku: 'EC-SHIRT-001', name: '夏季防晒衬衫', category: '服饰', channel: '淘宝', price: 129, stock: 80, publishStatus: 'online', imageStatus: 'optimized', updatedAt: '2026-07-09' },
{ id: 2, sku: 'EC-LIP-021', name: '丝绒口红套装', category: '美妆', channel: '小红书', price: 199, stock: 42, publishStatus: 'reviewing', imageStatus: 'pending', updatedAt: '2026-07-09' },
{ id: 3, sku: 'EC-LAMP-018', name: '床头阅读灯', category: '家居', channel: '京东', price: 89, stock: 120, publishStatus: 'online', imageStatus: 'optimized', updatedAt: '2026-07-08' },
{ id: 4, sku: 'EC-CAM-086', name: '智能云台摄像头', category: '数码', channel: '独立站', price: 269, stock: 18, publishStatus: 'draft', imageStatus: 'pending', updatedAt: '2026-07-08' },
{ id: 5, sku: 'EC-TEA-033', name: '冷泡茶组合', category: '食品', channel: '抖音', price: 59, stock: 240, publishStatus: 'online', imageStatus: 'failed', updatedAt: '2026-07-07' },
{ id: 6, sku: 'EC-BAG-012', name: '通勤托特包', category: '服饰', channel: '小红书', price: 159, stock: 35, publishStatus: 'offline', imageStatus: 'optimized', updatedAt: '2026-07-07' },
{ id: 7, sku: 'EC-CREAM-056', name: '修护面霜', category: '美妆', channel: '淘宝', price: 239, stock: 64, publishStatus: 'reviewing', imageStatus: 'pending', updatedAt: '2026-07-06' },
])
watch([keyword, categoryFilter, imageFilter, publishFilter], () => {
currentPage.value = 1
})
const filteredProducts = computed(() => {
const term = keyword.value.trim().toLowerCase()
return products.value.filter((product) => {
const matchesKeyword = !term
|| product.name.toLowerCase().includes(term)
|| product.sku.toLowerCase().includes(term)
|| product.channel.toLowerCase().includes(term)
const matchesCategory = !categoryFilter.value || product.category === categoryFilter.value
const matchesImage = !imageFilter.value || product.imageStatus === imageFilter.value
const matchesPublish = !publishFilter.value || product.publishStatus === publishFilter.value
return matchesKeyword && matchesCategory && matchesImage && matchesPublish
})
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredProducts.value.length / pageSize)))
const visiblePage = computed(() => Math.min(currentPage.value, totalPages.value))
const pagedProducts = computed(() => {
const start = (visiblePage.value - 1) * pageSize
return filteredProducts.value.slice(start, start + pageSize)
})
const summary = computed(() => [
{ label: '商品总数', value: String(products.value.length), tone: 'blue' as const },
{ label: '待优化图片', value: String(products.value.filter((item) => item.imageStatus === 'pending').length), tone: 'amber' as const },
{ label: '在售商品', value: String(products.value.filter((item) => item.publishStatus === 'online').length), tone: 'green' as const },
{ label: '图片失败', value: String(products.value.filter((item) => item.imageStatus === 'failed').length), tone: 'red' as const },
])
function createDraft() {
const nextID = Math.max(...products.value.map((item) => item.id), 0) + 1
products.value.unshift({
id: nextID,
sku: draftSku.value.trim() || `EC-SKU-${String(nextID).padStart(3, '0')}`,
name: draftName.value.trim() || '未命名商品',
category: draftCategory.value,
channel: draftChannel.value,
price: Number(draftPrice.value) || 0,
stock: Number(draftStock.value) || 0,
publishStatus: 'draft',
imageStatus: 'pending',
updatedAt: new Date().toISOString().slice(0, 10),
})
currentPage.value = 1
}
function resetFilters() {
keyword.value = ''
categoryFilter.value = ''
imageFilter.value = ''
publishFilter.value = ''
currentPage.value = 1
}
function applyFilters() {
currentPage.value = 1
}
function previousPage() {
currentPage.value = Math.max(1, currentPage.value - 1)
}
function nextPage() {
currentPage.value = Math.min(totalPages.value, currentPage.value + 1)
}
function formatPrice(value: number) {
return `¥${value.toFixed(2)}`
}
function publishLabel(status: PublishStatus) {
const labels: Record<PublishStatus, string> = {
draft: '草稿',
reviewing: '审核中',
online: '在售',
offline: '下架',
}
return labels[status]
}
function imageLabel(status: ImageStatus) {
const labels: Record<ImageStatus, string> = {
pending: '待优化',
optimized: '已优化',
failed: '失败',
}
return labels[status]
}
function statusClass(status: PublishStatus | ImageStatus) {
if (status === 'online' || status === 'optimized') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-200'
}
if (status === 'failed' || status === 'offline') {
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200'
}
if (status === 'reviewing') {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-200'
}
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-200'
}
</script>
<template>
<ModulePageShell
eyebrow="E-Commerce"
title="商品中心"
description="电商场景的商品运营入口,先沉淀 SKU、渠道、库存、上架状态和图片优化状态。"
status="EC 分支起点"
:items="summary"
>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<aside class="space-y-4">
<section class="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<h2 class="mb-4 text-base font-semibold text-slate-950 dark:text-white">
商品草稿
</h2>
<div class="space-y-4">
<label class="block">
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">商品名称</span>
<input v-model="draftName" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950">
</label>
<label class="block">
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">SKU</span>
<input v-model="draftSku" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950">
</label>
<div class="grid grid-cols-2 gap-3">
<label class="block">
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">类目</span>
<select v-model="draftCategory" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950">
<option v-for="item in categoryOptions" :key="item" :value="item">
{{ item }}
</option>
</select>
</label>
<label class="block">
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">渠道</span>
<select v-model="draftChannel" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950">
<option v-for="item in channelOptions" :key="item" :value="item">
{{ item }}
</option>
</select>
</label>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="block">
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">售价</span>
<input v-model.number="draftPrice" min="0" step="1" type="number" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950">
</label>
<label class="block">
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">库存</span>
<input v-model.number="draftStock" min="0" step="1" type="number" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950">
</label>
</div>
<button
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-slate-950 px-4 text-sm font-medium text-white dark:bg-white dark:text-slate-950"
type="button"
@click="createDraft"
>
<PlusOutlined />
<span>新增草稿</span>
</button>
</div>
</section>
<section class="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<h2 class="mb-3 text-base font-semibold text-slate-950 dark:text-white">
运营动作
</h2>
<div class="space-y-3 text-sm">
<RouterLink class="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2 text-slate-700 hover:bg-slate-50 dark:border-slate-800 dark:text-slate-200 dark:hover:bg-slate-950" to="/image-optimizer">
<span>商品图片 AI 优化</span>
<span class="text-xs text-slate-400">进入</span>
</RouterLink>
<RouterLink class="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2 text-slate-700 hover:bg-slate-50 dark:border-slate-800 dark:text-slate-200 dark:hover:bg-slate-950" to="/statistics">
<span>运营数据统计</span>
<span class="text-xs text-slate-400">进入</span>
</RouterLink>
</div>
</section>
</aside>
<section class="min-w-0 rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="grid grid-cols-1 gap-3 border-b border-slate-200 p-4 dark:border-slate-800 xl:grid-cols-[minmax(180px,1fr)_130px_130px_130px_auto_auto]">
<input
v-model="keyword"
class="h-10 rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950"
placeholder="搜索商品、SKU、渠道"
@keyup.enter="applyFilters"
>
<select v-model="categoryFilter" class="h-10 rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950" @change="applyFilters">
<option value="">全部类目</option>
<option v-for="item in categoryOptions" :key="item" :value="item">
{{ item }}
</option>
</select>
<select v-model="publishFilter" class="h-10 rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950" @change="applyFilters">
<option value="">上架状态</option>
<option value="draft">草稿</option>
<option value="reviewing">审核中</option>
<option value="online">在售</option>
<option value="offline">下架</option>
</select>
<select v-model="imageFilter" class="h-10 rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950" @change="applyFilters">
<option value="">图片状态</option>
<option value="pending">待优化</option>
<option value="optimized">已优化</option>
<option value="failed">失败</option>
</select>
<button class="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-slate-950 px-4 text-sm font-medium text-white dark:bg-white dark:text-slate-950" type="button" @click="applyFilters">
<SearchOutlined />
<span>筛选</span>
</button>
<button class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-4 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200 dark:hover:bg-slate-900" type="button" @click="resetFilters">
<ReloadOutlined />
<span>重置</span>
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead class="bg-slate-50 text-xs uppercase text-slate-500 dark:bg-slate-950 dark:text-slate-400">
<tr>
<th class="min-w-[220px] px-4 py-3 font-medium">商品</th>
<th class="w-[110px] px-4 py-3 font-medium">类目</th>
<th class="w-[110px] px-4 py-3 font-medium">渠道</th>
<th class="w-[110px] px-4 py-3 font-medium">售价</th>
<th class="w-[100px] px-4 py-3 font-medium">库存</th>
<th class="w-[120px] px-4 py-3 font-medium">上架</th>
<th class="w-[120px] px-4 py-3 font-medium">图片</th>
<th class="w-[120px] px-4 py-3 font-medium">更新</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-800">
<tr v-if="pagedProducts.length === 0">
<td class="px-4 py-8 text-center text-slate-500 dark:text-slate-400" colspan="8">
暂无商品
</td>
</tr>
<tr v-for="product in pagedProducts" :key="product.id" class="hover:bg-slate-50 dark:hover:bg-slate-950">
<td class="px-4 py-3">
<div class="font-medium text-slate-900 dark:text-white">
{{ product.name }}
</div>
<div class="text-xs text-slate-500 dark:text-slate-400">
{{ product.sku }}
</div>
</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-200">
{{ product.category }}
</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-200">
{{ product.channel }}
</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-200">
{{ formatPrice(product.price) }}
</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-200">
{{ product.stock }}
</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-md border px-2 py-1 text-xs font-medium" :class="statusClass(product.publishStatus)">
{{ publishLabel(product.publishStatus) }}
</span>
</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-md border px-2 py-1 text-xs font-medium" :class="statusClass(product.imageStatus)">
{{ imageLabel(product.imageStatus) }}
</span>
</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">
{{ product.updatedAt }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col gap-3 border-t border-slate-200 p-4 dark:border-slate-800 md:flex-row md:items-center md:justify-between">
<div class="text-sm text-slate-500 dark:text-slate-400">
{{ filteredProducts.length }} 个商品
</div>
<div class="flex items-center gap-3 text-sm">
<button class="h-9 rounded-md border border-slate-300 px-3 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700" type="button" :disabled="visiblePage === 1" @click="previousPage">
上一页
</button>
<span class="min-w-[72px] text-center text-slate-700 dark:text-slate-200">
{{ visiblePage }} / {{ totalPages }}
</span>
<button class="h-9 rounded-md border border-slate-300 px-3 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700" type="button" :disabled="visiblePage === totalPages" @click="nextPage">
下一页
</button>
</div>
</div>
</section>
</div>
</ModulePageShell>
</template>