Files
cmsp/frontend/src/views/ProductListView.vue
T
QiuSWandClaude Opus 5 5bcfea24ba feat: 状态默认选中在售中;明确不清理已下架商品
- 「状态」筛选默认值改回 NORMAL(在售中)
- 「类型」筛选默认 missing(缺少视频),两者配合后,打开程序即为
  「在售中且缺少视频」的商品,正是需要去淘宝搜视频的工作对象

已下架商品不清理(负责人 2026-09-03 决定):货憨憨那边删除或下架的
商品,本地保留不删。因此本地总数可能大于店铺当前商品数——负责人
本机为 5459 对 4935,多出的 524 条即已消失的商品。这是预期行为,
已写入文件头注释,避免后来人自作主张加自动清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-03 08:50:07 +08:00

612 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/**
* 商品列表页。
*
* 结构照原型 v4:
* 上行筛选:店铺 / 蝦皮ID / 创建时间范围 / 状态 / 类型 / 重置 / 搜索
* 下行操作:下载数据 / 下载视频 / 上传数据 / 运行日志
* 下面是表格和分页
*
* 前四个筛选条件对应货憨憨 getPage 的原生参数:
* 店铺 -> platformShopId
* 蝦皮ID -> itemIds
* 创建时间 -> createTimeStart / createTimeEnd
* 状态 -> itemStatus
* 「类型」筛选对应本地 SQLite 的 video_diagnosis,不会传给货憨憨。
* 默认选中「缺少视频」:这些才是需要拿主图去淘宝搜视频的商品,
* 也是本工具的主要工作对象。
*
* 注意:本页查的是本地 SQLite,不联网。点「下载数据」才会请求货憨憨。
*
* 已下架商品不清理:货憨憨那边删掉或下架的商品,本地会保留下来
* (2026-09-03 负责人决定)。所以本地总数可能大于店铺当前商品数,
* 这是预期行为,不要加自动清理。
*/
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
import { useDialog, useMessage } from 'naive-ui'
import { EventsOff, EventsOn } from '../../wailsjs/runtime/runtime'
import {
DownloadProductData,
GetCachedShops,
GetShopsUpdatedAt,
RefreshShops,
OpenInBrowser,
DownloadVideos,
ListProducts,
UploadVideos,
} from '../../wailsjs/go/main/App'
const message = useMessage()
const dialog = useDialog()
const emit = defineEmits(['open-logs'])
// ---- 筛选条件 ----
// 字段名和 Go 侧 store.ProductQuery 的 json tag 一一对应,改名要两边一起改。
const emptyQuery = () => ({
platformShopId: '',
itemIds: '',
createdFrom: '',
createdTo: '',
itemStatus: 'NORMAL',
videoDiagnosis: 'missing',
page: 1,
pageSize: 20,
})
const query = ref(emptyQuery())
// 商品状态取值来自货憨憨接口文档,不要随便加。
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '在售中', value: 'NORMAL' },
{ label: '已售罄', value: 'SOLD_OUT' },
{ label: '审查中', value: 'REVIEWING' },
{ label: '未上架', value: 'UNLIST' },
{ label: '被禁售', value: 'BANNED' },
]
const diagnosisOptions = [
{ label: '全部', value: '' },
{ label: '缺少视频', value: 'missing' },
{ label: '有视频', value: 'ok' },
]
// ---- 店铺下拉 ----
// 店铺列表必须来自货憨憨 erp/shop/all,不能在前端写死。
const shopOptions = ref([])
const shopLoading = ref(false)
const shopRefreshing = ref(false)
const shopsUpdatedAt = ref('')
// 平台店铺ID -> 地区码,例 { "1664202094": "TW" }
const shopRegions = ref({})
// 展开店铺下拉时按需刷新。
//
// 为什么不是每次展开都刷:刷新要先完成货憨憨登录,一次一两秒,
// 每点一下下拉框就卡一下很烦人。所以只有距上次刷新超过下面这个
// 间隔才真的去请求,其余时候直接用缓存。
const 店铺刷新间隔毫秒 = 5 * 60 * 1000
let 上次刷新时刻 = 0
function onShopDropdown(展开) {
if (!展开) return
if (shopRefreshing.value || shopLoading.value) return
if (Date.now() - 上次刷新时刻 < 店铺刷新间隔毫秒) return
// 静默刷新:失败不弹窗,下拉框继续用缓存。
// 启动时的刷新失败才弹窗,那是使用者需要立刻知道的。
refreshShops({ silent: true })
}
const shopsUpdatedText = computed(() =>
shopsUpdatedAt.value ? `更新于 ${formatCachedAt(shopsUpdatedAt.value)}` : '尚未同步'
)
function formatCachedAt(value) {
return value ? String(value).slice(0, 16) : ''
}
function applyShops(shops) {
const list = shops || []
shopOptions.value = list.map((s) => ({
label: `${s.shopName} (${s.region})`,
value: s.platformShopId,
}))
// 商品行里没有地区字段,但拼 Shopee 商品页地址要用。
// 这里按 平台店铺ID → 地区 建个表,行渲染时查。
shopRegions.value = Object.fromEntries(
list.map((s) => [s.platformShopId, s.region])
)
}
async function loadShops() {
shopLoading.value = true
try {
const [shops, updatedAt] = await Promise.all([
GetCachedShops(),
GetShopsUpdatedAt(),
])
applyShops(shops)
shopsUpdatedAt.value = updatedAt || ''
} catch (err) {
message.error(`读取店铺缓存失败:${err}`)
} finally {
shopLoading.value = false
}
}
function showRefreshFailure(payload) {
const reason = payload?.reason || '未知原因'
const cachedAt = payload?.cachedAt || ''
const cachedCount = Number(payload?.cachedCount || 0)
const cacheStatus = cachedAt
? `当前使用 ${formatCachedAt(cachedAt)} 缓存的 ${cachedCount} 个店铺,可能已过期。`
: `当前没有可用缓存(缓存时间:尚未同步,店铺数 ${cachedCount})。`
dialog.warning({
title: '店铺列表更新失败',
content: `店铺列表更新失败:${reason}\n${cacheStatus}`,
positiveText: '知道了',
})
}
function onShopsRefreshed(shops) {
applyShops(shops)
shopsUpdatedAt.value = shops?.[0]?.updatedAt || ''
}
function onShopsRefreshFailed(payload) {
if (payload?.cachedAt !== undefined) {
shopsUpdatedAt.value = payload.cachedAt || ''
}
showRefreshFailure(payload)
}
async function refreshShops({ silent = false } = {}) {
shopRefreshing.value = true
上次刷新时刻 = Date.now()
try {
const shops = await RefreshShops()
applyShops(shops)
shopsUpdatedAt.value = shops?.[0]?.updatedAt || (await GetShopsUpdatedAt()) || ''
if (!silent) {
message.success(`店铺列表已更新,共 ${shops?.length || 0} 个店铺`)
}
} catch (err) {
// 展开下拉时的自动刷新失败不打扰使用者:下拉框继续用缓存,
// 旁边的「更新于 …」会显示成旧时间,看得出来数据不是最新的。
// 启动时的刷新失败才弹窗,那是需要立刻知道的。
if (silent) {
return
}
let cachedAt = shopsUpdatedAt.value
try {
cachedAt = (await GetShopsUpdatedAt()) || ''
} catch (_) {
// 刷新错误才是弹窗的主要原因;读取缓存时间失败时按尚未同步展示。
}
showRefreshFailure({
reason: String(err),
cachedAt,
cachedCount: shopOptions.value.length,
})
} finally {
shopRefreshing.value = false
}
}
// ---- 表格 ----
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const checkedIds = ref([])
const columns = [
{ type: 'selection' },
{
title: '主图',
key: 'mainImage',
width: 64,
render: (row) =>
row.mainImage
? h('img', { src: row.mainImage, class: 'thumb' })
: h('span', { class: 'thumb empty' }, 'IMG'),
},
{ title: '蝦皮ID', key: 'itemId', width: 118 },
{ title: '标题', key: 'itemName', ellipsis: { tooltip: true } },
{ title: '店铺', key: 'shopName', width: 120 },
{
title: '价格',
key: 'minSkuPrice',
width: 100,
render: (row) => `${row.currency} ${row.minSkuPrice}`,
},
{
title: '视频',
key: 'videoDiagnosis',
width: 88,
render: (row) => renderStatus(row.videoDiagnosis, diagnosisText),
},
{
title: '下载状态',
key: 'downloadStatus',
width: 110,
render: (row) => renderStatus(row.downloadStatus, downloadText),
},
{
title: '上传状态',
key: 'uploadStatus',
width: 100,
render: (row) => renderStatus(row.uploadStatus, uploadText),
},
{ title: '创建时间', key: 'createdAt', width: 150 },
{
title: '操作',
key: 'actions',
width: 130,
render: (row) => {
const url = shopeeURL(row)
return h('div', { class: 'ops' }, [
// 详情:跳到 Shopee 商品页。地区认不出来时置灰,
// 免得点了打开一个 404。
h(
'a',
{
class: url ? 'op' : 'op off',
title: url || '认不出店铺地区,无法拼出 Shopee 地址',
onClick: () => url && openShopee(row),
},
'详情'
),
// 目录:打开本地视频所在文件夹。视频还没下载时没有目录可开,
// 等 R4(工单 #5 MVP2)实现下载后再解开这里的禁用。
h(
'a',
{
class: 'op off',
title: '下载视频后可用(需求 R4)',
},
'目录'
),
])
},
},
]
// 状态值到中文和颜色的对照。
// Go 侧的常量在 internal/store/product.go,改一边要改两边。
const diagnosisText = { missing: ['缺少视频', 'bad'], ok: ['有视频', 'ok'] }
const downloadText = {
pending: ['待下载', 'muted'],
running: ['下载中', 'primary'],
done: ['已下载', 'ok'],
failed: ['失败', 'bad'],
}
const uploadText = {
pending: ['待上传', 'warn'],
running: ['上传中', 'primary'],
done: ['已上传', 'ok'],
failed: ['失败', 'bad'],
}
// Shopee 各站点域名。地区码来自货憨憨的 region 字段。
// 缺哪个站点就在这里补一行,不要在别处硬编码域名。
const shopeeDomains = {
TW: 'shopee.tw',
MY: 'shopee.com.my',
SG: 'shopee.sg',
TH: 'shopee.co.th',
VN: 'shopee.vn',
PH: 'shopee.ph',
ID: 'shopee.co.id',
BR: 'shopee.com.br',
}
// 拼商品在 Shopee 上的地址。地区不认识就返回空字符串,
// 调用方据此把「详情」置灰,而不是拼一个打不开的地址。
function shopeeURL(row) {
const domain = shopeeDomains[shopRegions.value[row.platformShopId]]
if (!domain || !row.platformShopId || !row.itemId) return ''
return `https://${domain}/product/${row.platformShopId}/${row.itemId}`
}
async function openShopee(row) {
const url = shopeeURL(row)
if (!url) return
try {
await OpenInBrowser(url)
} catch (err) {
message.error(`打开商品页失败:${err}`)
}
}
function renderStatus(value, dict) {
const [text, tone] = dict[value] || [value || '—', 'faint']
return h('span', { class: `st ${tone}` }, text)
}
async function search(resetPage = true) {
if (resetPage) query.value.page = 1
loading.value = true
try {
const page = await ListProducts(query.value)
rows.value = page.items || []
total.value = page.total || 0
} catch (err) {
message.error(String(err))
} finally {
loading.value = false
}
}
function reset() {
query.value = emptyQuery()
search()
}
// ---- 操作按钮 ----
async function run(action, fn) {
running.value = action
try {
await fn()
await search(false)
} catch (err) {
message.warning(`${action}:${err}`)
} finally {
running.value = ''
}
}
async function downloadProductData() {
if (!query.value.platformShopId) {
message.warning('请先在上面选择一个店铺')
return
}
// 商品多的店铺要拉几十页,耗时十几秒到几分钟。
// 必须先给出可见反馈,否则按钮看起来像坏了。
running.value = '下载数据'
message.info('正在下载商品数据,进度见「运行日志」')
try {
await DownloadProductData(query.value.platformShopId)
await search(false)
message.success(`下载完成,本店铺共 ${total.value} 条商品`)
} catch (err) {
message.error(`下载数据失败:${err}`)
} finally {
running.value = ''
}
}
// 正在跑的操作名。非空时按钮禁用并显示转圈,
// 否则长耗时操作期间界面毫无反馈,使用者会以为点了没用。
const running = ref('')
const selectedCount = computed(() => checkedIds.value.length)
onMounted(() => {
// 先监听再读缓存:后台刷新若恰好此时完成,不会漏掉最新列表。
EventsOn('shops:refreshed', onShopsRefreshed)
EventsOn('shops:refresh-failed', onShopsRefreshFailed)
loadShops()
search()
})
onUnmounted(() => {
EventsOff('shops:refreshed')
EventsOff('shops:refresh-failed')
})
</script>
<template>
<div class="page">
<!-- 工具栏:上行筛选,下行操作。一行放不下,别改回一行。 -->
<div class="toolbar">
<div class="row">
<span class="lb">店铺</span>
<n-select
v-model:value="query.platformShopId"
:options="shopOptions"
:loading="shopLoading || shopRefreshing"
placeholder="全部店铺"
clearable
style="width: 200px"
@update:show="onShopDropdown"
/>
<span class="shop-updated">{{ shopsUpdatedText }}</span>
<span class="lb">蝦皮ID</span>
<n-input
v-model:value="query.itemIds"
placeholder="多个用逗号分隔"
style="width: 170px"
/>
<span class="lb">创建时间</span>
<n-input v-model:value="query.createdFrom" placeholder="2026-08-01" style="width: 130px" />
<span class="dash">—</span>
<n-input v-model:value="query.createdTo" placeholder="2026-09-02" style="width: 130px" />
<span class="lb">状态</span>
<n-select
v-model:value="query.itemStatus"
:options="statusOptions"
style="width: 130px"
/>
<span class="lb">类型</span>
<n-select
v-model:value="query.videoDiagnosis"
:options="diagnosisOptions"
style="width: 130px"
/>
<n-button style="margin-left: 12px" @click="reset">重置</n-button>
<n-button type="primary" @click="search()">搜索</n-button>
</div>
<div class="row">
<n-button
type="primary"
:loading="running === '下载数据'"
:disabled="running !== '' && running !== '下载数据'"
@click="downloadProductData"
>
下载数据
</n-button>
<n-button
:loading="running === '下载视频'"
:disabled="running !== '' && running !== '下载视频'"
@click="run('下载视频', () => DownloadVideos(checkedIds))"
>
下载视频
</n-button>
<n-button
:loading="running === '上传数据'"
:disabled="running !== '' && running !== '上传数据'"
@click="run('上传数据', () => UploadVideos(checkedIds))"
>
上传数据
</n-button>
<n-button @click="emit('open-logs')">运行日志</n-button>
<span class="spacer" />
<span class="count">已选 {{ selectedCount }} / 共 {{ total }} 条</span>
</div>
</div>
<!-- 表格。选中的行 ID 存到 checkedIds,操作按钮拿它做批量。 -->
<div class="table selectable">
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:row-key="(row) => row.id"
v-model:checked-row-keys="checkedIds"
flex-height
style="height: 100%"
/>
</div>
<div class="pager">
<span class="muted">
第 {{ query.page }} / {{ Math.max(1, Math.ceil(total / query.pageSize)) }} 页
&nbsp;&nbsp; 每页 {{ query.pageSize }} 条 &nbsp;&nbsp; 共 {{ total }} 条
</span>
<n-pagination
v-model:page="query.page"
:item-count="total"
:page-size="query.pageSize"
@update:page="search(false)"
/>
</div>
</div>
</template>
<style scoped>
.page {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg);
}
.toolbar {
background: var(--card);
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
/* 窄窗口下允许换行。用 min-height 而不是固定 height,
否则换行后的控件会被裁掉一半。 */
flex-wrap: wrap;
min-height: 48px;
padding: 6px 24px;
}
.row:first-child {
border-bottom: 1px solid #f1f2f4;
}
.lb {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.dash {
color: var(--muted);
}
.spacer {
flex: 1;
}
.count {
color: var(--muted);
font-size: 12px;
}
.shop-updated {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.table {
flex: 1;
min-height: 0;
background: var(--card);
}
.pager {
height: 44px;
background: var(--card);
border-top: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
}
.muted {
color: var(--muted);
font-size: 12px;
}
/* 表格里的状态文字配色,和原型一致 */
:deep(.st.ok) {
color: var(--ok);
}
:deep(.st.warn) {
color: var(--warn);
}
:deep(.st.bad) {
color: var(--bad);
}
:deep(.st.primary) {
color: var(--primary);
}
:deep(.st.muted) {
color: var(--muted);
}
:deep(.st.faint) {
color: var(--faint);
}
:deep(.ops) {
display: flex;
gap: 12px;
}
:deep(.op) {
color: var(--primary);
cursor: pointer;
}
:deep(.op.off) {
color: var(--faint);
cursor: not-allowed;
}
:deep(.thumb) {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 4px;
background: #e9eaee;
}
:deep(.thumb.empty) {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: var(--faint);
}
</style>