增加和平小店商品分类展示
This commit is contained in:
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user