feat: start ecommerce product center
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user