增加和平小店商品分类展示

This commit is contained in:
yml2213
2026-08-08 18:29:31 +08:00
parent 06ad1498da
commit 51e91b0483
7 changed files with 168 additions and 52 deletions
@@ -5,6 +5,7 @@ import { useActivity } from '@/composables/useActivity'
import ProductSearch from '@/components/product/ProductSearch.vue'
import ProductList from '@/components/product/ProductList.vue'
import ProductCard from '@/components/product/ProductCard.vue'
import XiaodianTabs from '@/components/product/XiaodianTabs.vue'
defineProps<{
version: string
@@ -67,6 +68,8 @@ const onSwitchActivity = async (e: Event) => {
<ProductSearch />
<XiaodianTabs v-if="isXiaodian" />
<ProductCard />
<div class="product-meta" :class="{ good: productInfo && !productInfo.stale, warn: productInfo?.stale }">
@@ -4,7 +4,7 @@ import { ref, computed, watch } from 'vue'
import { useProducts } from '@/composables/useProducts'
import { useActivity } from '@/composables/useActivity'
const { filteredProducts, selectedProductId, selectProduct, searchQuery } = useProducts()
const { filteredProducts, selectedProductId, selectProduct, searchQuery, xiaodianTab } = useProducts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const priceLabel = computed(() => isXiaodian.value ? '点券' : '积分')
@@ -25,6 +25,10 @@ const virtualizer = useVirtualizer(
const handleSelect = (commodityId: string, commodityName: string) => {
selectProduct(commodityId, commodityName)
}
watch(xiaodianTab, () => {
virtualizer.value.scrollToOffset(0)
})
</script>
<template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useProducts, type XiaodianTab } from '@/composables/useProducts'
const { xiaodianTab, setXiaodianTab } = useProducts()
const tabs: Array<{ id: XiaodianTab, label: string }> = [
{ id: 'all', label: '全部商品' },
{ id: 'discount', label: '特惠专区' },
{ id: 'hot', label: '热门抢购' }
]
</script>
<template>
<div class="xiaodian-tabs" role="tablist" aria-label="和平小店商品分类">
<button
v-for="tab in tabs"
:key="tab.id"
class="xiaodian-tab"
:class="{ active: xiaodianTab === tab.id }"
type="button"
role="tab"
:aria-selected="xiaodianTab === tab.id"
@click="setXiaodianTab(tab.id)"
>{{ tab.label }}</button>
</div>
</template>
<style scoped>
.xiaodian-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 3px;
gap: 2px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--field);
}
.xiaodian-tab {
min-width: 0;
height: 28px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 11px;
white-space: nowrap;
}
.xiaodian-tab:hover {
color: var(--text-strong);
background: var(--surface-2);
}
.xiaodian-tab.active {
color: var(--accent);
background: var(--accent-soft);
font-weight: 600;
}
</style>
+19 -4
View File
@@ -1,10 +1,12 @@
import { ref, computed } from 'vue'
import { useToast } from './useToast'
import { useActivity } from './useActivity'
import { LoadProducts } from '@wails/go/app/App'
export interface Product {
commodityId: string
commodityName: string
category: string
score: number
count: number
pic: string
@@ -32,18 +34,25 @@ const selectedProductId = ref<string | null>(null)
const selectedProductName = ref('')
const searchQuery = ref('')
const loading = ref(false)
export type XiaodianTab = 'all' | 'discount' | 'hot'
const xiaodianTab = ref<XiaodianTab>('all')
export function useProducts() {
const { showToast } = useToast()
const { currentActivity } = useActivity()
const filteredProducts = computed(() => {
const keyword = searchQuery.value.toLowerCase().trim()
if (!keyword) return products.value
return products.value.filter(p =>
[p.commodityName, p.commodityId, p.score, p.count].some(
return products.value.filter(p => {
const matchesTab = currentActivity.value?.id !== 'xiaodian' || xiaodianTab.value === 'all'
|| (xiaodianTab.value === 'discount' && p.category === '45')
|| (xiaodianTab.value === 'hot' && p.category === '76')
if (!matchesTab) return false
if (!keyword) return true
return [p.commodityName, p.commodityId, p.score, p.count].some(
v => String(v ?? '').toLowerCase().includes(keyword)
)
)
})
})
const selectedProduct = computed(() =>
@@ -97,6 +106,10 @@ export function useProducts() {
searchQuery.value = ''
}
const setXiaodianTab = (tab: XiaodianTab) => {
xiaodianTab.value = tab
}
const restoreSelection = () => {
const savedId = localStorage.getItem('selProd')
const savedName = localStorage.getItem('selProdName')
@@ -115,11 +128,13 @@ export function useProducts() {
searchQuery,
filteredProducts,
loading,
xiaodianTab,
loadProducts,
selectProduct,
clearSelection,
setSearch,
clearSearch,
setXiaodianTab,
restoreSelection
}
}
+57 -33
View File
@@ -244,14 +244,15 @@ type xiaodianRecommendation struct {
}
type xiaodianItem struct {
GoodsID string `json:"iGoodsId"`
GoodsName string `json:"sGoodsName"`
Price string `json:"iPrice"`
GoodsLeft string `json:"iGoodsLeft"`
GoodsPic string `json:"sGoodsPic"`
ShowBegin string `json:"dtShowBeginTime"`
ShowEnd string `json:"dtShowEndTime"`
ActionID string `json:"iActionId"`
GoodsID string `json:"iGoodsId"`
GoodsName string `json:"sGoodsName"`
Category xiaodianFlexibleString `json:"iCategoryId"`
Price string `json:"iPrice"`
GoodsLeft string `json:"iGoodsLeft"`
GoodsPic string `json:"sGoodsPic"`
ShowBegin string `json:"dtShowBeginTime"`
ShowEnd string `json:"dtShowEndTime"`
ActionID string `json:"iActionId"`
}
type xiaodianFlexibleString string
@@ -392,7 +393,7 @@ func parseXiaodianVariable(body []byte, name string, out any) error {
return json.Unmarshal([]byte(text), out)
}
// xiaodianProducts 查询和平小店全部商品,接口单页最多返回 200 条。
// xiaodianProducts 查询和平小店的全部、特惠专区及热门抢购商品。
func (c *DouyuClient) xiaodianProducts(cookie string, options types.XiaodianProductListOptions) ([]types.Product, error) {
if strings.TrimSpace(cookie) == "" {
return nil, errors.New("和平小店需要至少一个有效斗鱼 Cookie")
@@ -433,53 +434,76 @@ func (c *DouyuClient) xiaodianProducts(cookie string, options types.XiaodianProd
query.Set("desc", "1")
query.Set("_act_id", c.Activity.ActID)
query.Set("actid", c.Activity.ActID)
query.Set("excludeFields", "iCategoryId_76")
query.Set("userid", string(role.OpenID))
query.Set("roleid", string(role.RoleID))
query.Set("area", "1")
query.Set("areaid", strconv.Itoa(areaID))
query.Set("need_limit", "1")
products := make([]types.Product, 0)
// 抓包中的“全部商品”会排除热门抢购(分类 76),因此需要再查询一次热门分类后合并。
normalItems, err := c.xiaodianProductPages(endpoint, query, options.PageSize, "", "76", "和平小店全部商品")
if err != nil {
return nil, err
}
hotItems, err := c.xiaodianProductPages(endpoint, query, options.PageSize, "76", "", "和平小店热门抢购")
if err != nil {
return nil, err
}
products := make([]types.Product, 0, len(normalItems)+len(hotItems))
seen := make(map[string]struct{})
for _, item := range append(normalItems, hotItems...) {
if item.GoodsID == "" {
continue
}
if _, ok := seen[item.GoodsID]; ok {
continue
}
seen[item.GoodsID] = struct{}{}
products = append(products, types.Product{
CommodityID: item.GoodsID,
CommodityName: item.GoodsName,
Category: string(item.Category),
Score: parseXiaodianInt(item.Price),
Count: parseXiaodianInt(item.GoodsLeft),
Pic: item.GoodsPic,
})
}
return products, nil
}
// xiaodianProductPages 按分类拉取完整分页;分类为空时查询全部商品。
func (c *DouyuClient) xiaodianProductPages(endpoint *url.URL, query url.Values, pageSize int, category, excludeCategory, label string) ([]xiaodianItem, error) {
itemsAll := make([]xiaodianItem, 0)
for offset, pageNum := 0, 1; ; pageNum++ {
pageQuery := make(url.Values, len(query))
for key, values := range query {
pageQuery[key] = append([]string(nil), values...)
}
if category != "" {
pageQuery.Set("category", category)
}
if excludeCategory != "" {
pageQuery.Set("excludeFields", "iCategoryId_"+excludeCategory)
}
pageQuery.Set("page_begin", strconv.Itoa(offset))
pageQuery.Set("page_num", strconv.Itoa(pageNum))
items, err := c.xiaodianProductPage(endpoint, pageQuery)
items, err := c.xiaodianProductPage(endpoint, pageQuery, label)
if err != nil {
return nil, err
}
for _, item := range items {
if item.GoodsID == "" {
continue
}
if _, ok := seen[item.GoodsID]; ok {
continue
}
seen[item.GoodsID] = struct{}{}
products = append(products, types.Product{
CommodityID: item.GoodsID,
CommodityName: item.GoodsName,
Score: parseXiaodianInt(item.Price),
Count: parseXiaodianInt(item.GoodsLeft),
Pic: item.GoodsPic,
})
}
if len(items) < options.PageSize {
itemsAll = append(itemsAll, items...)
if len(items) < pageSize {
break
}
if pageNum >= 100 {
return nil, errors.New("和平小店商品分页超出安全上限")
}
offset += options.PageSize
offset += pageSize
}
return products, nil
return itemsAll, nil
}
func (c *DouyuClient) xiaodianProductPage(endpoint *url.URL, query url.Values) ([]xiaodianItem, error) {
func (c *DouyuClient) xiaodianProductPage(endpoint *url.URL, query url.Values, label string) ([]xiaodianItem, error) {
pageURL := *endpoint
pageURL.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, pageURL.String(), nil)
@@ -487,7 +511,7 @@ func (c *DouyuClient) xiaodianProductPage(endpoint *url.URL, query url.Values) (
return nil, err
}
c.setXiaodianHeaders(req)
body, err := c.doText(req, "和平小店商品列表", false)
body, err := c.doText(req, label, false)
if err != nil {
return nil, err
}
+22 -14
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
@@ -129,7 +128,7 @@ func TestNetworkDiagnosticIncludesActivityAndFieldNamesOnly(t *testing.T) {
}
func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) {
var goodsOffsets []string
var goodsRequests []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/embed":
@@ -159,7 +158,9 @@ func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) {
}
case "/goods":
offset := r.URL.Query().Get("page_begin")
goodsOffsets = append(goodsOffsets, offset)
category := r.URL.Query().Get("category")
exclude := r.URL.Query().Get("excludeFields")
goodsRequests = append(goodsRequests, category+":"+exclude+":"+offset)
if got := r.URL.Query().Get("areaid"); got != "2" {
t.Errorf("areaid = %q", got)
}
@@ -167,11 +168,18 @@ func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) {
t.Errorf("page_size = %q", got)
}
items := []map[string]string{}
switch offset {
case "0":
items = []map[string]string{{"iGoodsId": "g1", "sGoodsName": "商品一", "iPrice": "10", "iGoodsLeft": "-1"}, {"iGoodsId": "g2", "sGoodsName": "商品二", "iPrice": "20", "iGoodsLeft": "3"}}
case "2":
items = []map[string]string{{"iGoodsId": "g2", "sGoodsName": "重复商品", "iPrice": "20", "iGoodsLeft": "3"}, {"iGoodsId": "g3", "sGoodsName": "商品三", "iPrice": "30", "iGoodsLeft": "0"}}
if category == "76" {
switch offset {
case "0":
items = []map[string]string{{"iGoodsId": "g3", "sGoodsName": "热门商品", "iCategoryId": "76", "iPrice": "30", "iGoodsLeft": "0"}}
}
} else {
switch offset {
case "0":
items = []map[string]string{{"iGoodsId": "g1", "sGoodsName": "商品一", "iCategoryId": "1", "iPrice": "10", "iGoodsLeft": "-1"}, {"iGoodsId": "g2", "sGoodsName": "特惠商品", "iCategoryId": "45", "iPrice": "20", "iGoodsLeft": "3"}}
case "2":
items = []map[string]string{{"iGoodsId": "g2", "sGoodsName": "重复商品", "iCategoryId": "45", "iPrice": "20", "iGoodsLeft": "3"}, {"iGoodsId": "g4", "sGoodsName": "商品四", "iCategoryId": "1", "iPrice": "40", "iGoodsLeft": "0"}}
}
}
body, _ := json.Marshal(map[string]any{"data": map[string]any{"client_data": map[string]any{"itemsdetail": items}}})
_, _ = w.Write(append([]byte("var recommend="), append(body, ';')...))
@@ -201,17 +209,17 @@ func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) {
if err != nil {
t.Fatalf("Products() error = %v", err)
}
if len(products) != 3 {
t.Fatalf("商品数量 = %d,期望 3", len(products))
if len(products) != 4 {
t.Fatalf("商品数量 = %d,期望 4", len(products))
}
if products[0].CommodityID != "g1" || products[2].CommodityID != "g3" {
if products[0].CommodityID != "g1" || products[3].CommodityID != "g3" {
t.Fatalf("商品顺序 = %#v", products)
}
if products[1].Count != 3 || products[2].Score != 30 {
if products[1].Category != "45" || products[3].Score != 30 {
t.Fatalf("商品字段 = %#v", products)
}
if got := strings.Join(goodsOffsets, ","); got != strings.Join([]string{"0", strconv.Itoa(2), strconv.Itoa(4)}, ",") {
t.Fatalf("分页偏移 = %s", got)
if got := strings.Join(goodsRequests, ","); got != ":iCategoryId_76:0,:iCategoryId_76:2,:iCategoryId_76:4,76::0" {
t.Fatalf("分类分页请求 = %s", got)
}
assets, err := cli.XiaodianAssets("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid")
if err != nil {
+1
View File
@@ -70,6 +70,7 @@ type Account struct {
type Product struct {
CommodityID string `json:"commodityId"`
CommodityName string `json:"commodityName"`
Category string `json:"category"`
Score int `json:"score"`
Count int `json:"count"`
Pic string `json:"pic"`