- 「状态」筛选默认值改回 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
612 lines
17 KiB
Vue
612 lines
17 KiB
Vue
<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)) }} 页
|
||
每页 {{ query.pageSize }} 条 共 {{ 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>
|