Files
medical-mall/pages/mall/consumer/favorites.uvue
2026-06-04 18:32:08 +08:00

1770 lines
42 KiB
Plaintext
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.
<!-- 我的收藏 -->
<template>
<view class="favorites-page">
<view class="favorites-nav">
<view class="nav-left" @click="handleBack">
<text class="back-icon"></text>
</view>
<view v-if="!isSearchMode" class="nav-center">
<text class="nav-title">我的收藏</text>
</view>
<view v-else class="nav-search-panel">
<view class="search-input-wrap">
<view class="search-icon">
<view class="search-icon-circle"></view>
<view class="search-icon-handle"></view>
</view>
<input class="search-input" :value="searchKeyword" placeholder="搜索收藏的商品、服务或店铺" @input="onSearchInput" />
<text v-if="searchKeyword != ''" class="search-clear" @click="clearSearchKeyword">×</text>
</view>
</view>
<view class="nav-right">
<text v-if="isSearchMode" class="nav-action-text" @click="exitSearchMode">取消</text>
<text v-else-if="isEditMode" class="nav-action-text" @click="toggleManageMode">完成</text>
<view v-else class="nav-actions-normal">
<view class="nav-search-trigger" @click="enterSearchMode">
<view class="search-icon nav-search-icon-small">
<view class="search-icon-circle"></view>
<view class="search-icon-handle"></view>
</view>
</view>
<text :class="['nav-action-text', { 'nav-action-disabled': totalItemsCount == 0 }]" @click="toggleManageMode">管理</text>
</view>
</view>
</view>
<scroll-view :class="favoritesContentClass" direction="vertical" scroll-y="true">
<view v-if="isLoading" class="state-box">
<text class="state-title">加载中...</text>
</view>
<view v-else-if="totalItemsCount == 0" class="state-box empty-box">
<text class="state-icon">♡</text>
<text class="state-title">暂无收藏内容</text>
<text class="state-subtitle">快去收藏感兴趣的商品或服务吧</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<view v-else-if="visibleItemsCount == 0" class="state-box empty-box">
<text class="state-icon">⌕</text>
<text class="state-title">未找到相关收藏</text>
<text class="state-subtitle">换个关键词再试试</text>
</view>
<view v-else class="group-list">
<view v-for="group in filteredGroups" :key="group.group_id" class="group-section">
<view class="group-header">
<view v-if="group.group_logo_url != ''" class="group-logo-wrap">
<image class="group-logo" :src="group.group_logo_url" mode="aspectFill" />
</view>
<view v-else class="group-logo-fallback">
<text class="group-logo-text">{{ getGroupInitial(group.group_name) }}</text>
</view>
<view class="group-info">
<text class="group-name">{{ group.group_name }}</text>
<view v-if="group.group_tags.length > 0" class="group-tag-row">
<text v-for="(groupTag, groupTagIndex) in group.group_tags" :key="group.group_id + '-tag-' + groupTagIndex" class="group-tag">{{ groupTag }}</text>
</view>
</view>
<text class="group-arrow"></text>
</view>
<view v-for="(item, itemIndex) in group.items" :key="item.favorite_id" :class="['favorite-row', { 'favorite-row-last': itemIndex == group.items.length - 1 }]" @click="handleItemClick(item)">
<view class="selector-column">
<view v-if="isEditMode" :class="['select-icon', { selected: item.manage_selected == true }]" @click.stop="toggleManageSelect(item)">
<text v-if="item.manage_selected == true" class="icon-text">✓</text>
</view>
<view v-else-if="item.target_type == 'product'" :class="['select-icon', { selected: item.purchase_selected == true, disabled: item.can_purchase == false }]" @click.stop="togglePurchaseSelect(item)">
<text v-if="item.purchase_selected == true" class="icon-text">✓</text>
</view>
<view v-else class="selector-placeholder"></view>
</view>
<image class="favorite-image" :src="item.image_url" mode="aspectFill" @error="handleImageError(item)" @click.stop="openFavorite(item, false)" />
<view class="favorite-main" @click.stop="openFavorite(item, false)">
<text class="favorite-title" :lines="2">{{ item.title }}</text>
<view v-if="item.display_tags.length > 0" class="tag-row">
<text v-for="(tag, tagIndex) in item.display_tags" :key="item.favorite_id + '-tag-' + tagIndex" class="tag-chip">{{ tag }}</text>
</view>
<text v-if="item.description_text != ''" class="favorite-desc" :lines="2">{{ item.description_text }}</text>
<text v-if="item.availability_status != ''" class="favorite-status">{{ item.availability_status }}</text>
<view v-if="!isEditMode && item.target_type == 'product'" class="spec-row">
<text class="spec-label">{{ getSelectedSkuSummary(item) }}</text>
<text v-if="item.has_skus" class="spec-action" @click.stop="openSkuPopup(item)">{{ item.selected_sku_id == '' ? '选规格' : '重选' }}</text>
</view>
<view class="favorite-footer">
<view class="price-wrap">
<text class="current-price">¥{{ formatPrice(getDisplayPrice(item)) }}</text>
<text v-if="item.has_original_price" class="original-price">¥{{ formatPrice(item.original_price) }}</text>
</view>
<view v-if="!isEditMode && item.target_type == 'product'" class="purchase-actions">
<text class="quantity-tip">{{ getQuantityHint(item) }}</text>
<view class="quantity-stepper">
<view :class="['step-btn', { disabled: canDecreaseQuantity(item) == false }]" @click.stop="decreaseItemQuantity(item)">
<text class="step-text">-</text>
</view>
<text class="quantity-value">{{ item.quantity }}</text>
<view :class="['step-btn', { disabled: canIncreaseQuantity(item) == false }]" @click.stop="increaseItemQuantity(item)">
<text class="step-text">+</text>
</view>
</view>
</view>
<button v-else-if="!isEditMode && item.target_type == 'service' && item.can_appoint == true" class="service-action-btn" @click.stop="openFavorite(item, true)">去预约</button>
</view>
</view>
<view v-if="!isEditMode" class="more-column" @click.stop="openMoreActions(item)">
<view class="more-dot"></view>
<view class="more-dot"></view>
<view class="more-dot"></view>
</view>
</view>
</view>
</view>
</scroll-view>
<view v-if="totalItemsCount > 0 && !isEditMode" class="purchase-bar">
<view class="bottom-left" @click="togglePurchaseSelectAll">
<view :class="['select-icon', { selected: isPurchaseAllSelected }]">
<text v-if="isPurchaseAllSelected" class="icon-text">✓</text>
</view>
<text class="bottom-text">全选</text>
</view>
<view class="purchase-total-box">
<text class="purchase-count-text">已选 {{ selectedPurchaseCount }} 件</text>
<text class="purchase-total-text">合计:<text class="purchase-total-amount">¥{{ formatAmount(selectedPurchaseTotalAmount) }}</text></text>
</view>
<button :class="['checkout-btn', { 'checkout-btn-disabled': selectedPurchaseItems.length == 0 }]" @click="goToCheckout">去结算</button>
</view>
<view v-if="isEditMode && totalItemsCount > 0" class="manage-bar">
<view class="bottom-left" @click="toggleManageSelectAll">
<view :class="['select-icon', { selected: isManageAllSelected }]">
<text v-if="isManageAllSelected" class="icon-text">✓</text>
</view>
<text class="bottom-text">全选</text>
</view>
<button :class="['delete-btn', { 'delete-btn-disabled': selectedManageCount == 0 }]" @click="deleteSelected">删除{{ selectedManageCount > 0 ? '(' + selectedManageCount + ')' : '' }}</button>
</view>
<page-container
v-if="skuPopupVisible"
:show="skuPopupVisible"
position="bottom"
:round="true"
:overlay="true"
:close-on-slide-down="true"
@clickoverlay="closeSkuPopup"
@afterleave="onSkuPopupAfterLeave"
>
<view class="sku-popup">
<view class="sku-popup-header">
<image class="sku-popup-image" :src="getPopupImage()" mode="aspectFill" />
<view class="sku-popup-summary">
<text class="sku-popup-price">¥{{ formatPrice(getPopupPrice()) }}</text>
<view v-if="activePurchaseItem != null && activePurchaseItem.display_tags.length > 0" class="tag-row popup-tag-row">
<text v-for="(tag, tagIndex) in activePurchaseItem.display_tags" :key="activePurchaseItem.favorite_id + '-popup-tag-' + tagIndex" class="tag-chip">{{ tag }}</text>
</view>
<text class="sku-popup-selected">已选择:{{ getPopupSelectedSummary() }}</text>
</view>
<text class="sku-popup-close" @click="closeSkuPopup">×</text>
</view>
<view v-if="skuPopupLoading" class="sku-popup-state">
<text class="sku-popup-state-text">规格加载中...</text>
</view>
<view v-else-if="skuPopupErrorMessage != ''" class="sku-popup-state">
<text class="sku-popup-state-text">{{ skuPopupErrorMessage }}</text>
<text class="sku-popup-retry" @click="retryPopupSkuLoad">重试</text>
</view>
<scroll-view v-else class="sku-popup-scroll" scroll-y="true">
<view class="sku-section">
<text class="sku-section-title">规格</text>
<view class="sku-chip-row">
<view v-for="sku in popupSkuList" :key="sku.sku_id" :class="getPopupSkuClass(sku)" @click="selectPopupSku(sku)">
<text :class="['sku-chip-text', { 'sku-chip-text-disabled': popupSkuDisabled(sku) }]">{{ sku.spec_text }}</text>
</view>
</view>
</view>
<view class="sku-section quantity-section">
<text class="sku-section-title">数量</text>
<view class="popup-stepper">
<view :class="['step-btn', { disabled: popupQuantity <= 1 }]" @click="decreasePopupQuantity">
<text class="step-text">-</text>
</view>
<text class="popup-quantity-value">{{ popupQuantity }}</text>
<view :class="['step-btn', { disabled: canIncreasePopupQuantity() == false }]" @click="increasePopupQuantity">
<text class="step-text">+</text>
</view>
</view>
<text class="popup-stock-text">库存 {{ getPopupStock() }} 件</text>
</view>
</scroll-view>
<view class="sku-popup-footer">
<button :class="['sku-confirm-btn', { 'sku-confirm-btn-disabled': canConfirmPopupSelection() == false }]" @click="confirmPopupSelection">确定</button>
</view>
</view>
</page-container>
</view>
</template>
<script setup lang="uts">
import { computed, onMounted, ref } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
import type { FavoriteGroup, FavoritePurchaseItem, FavoriteSkuItem, ProductSku } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
const groups = ref<Array<FavoriteGroup>>([])
const isEditMode = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const isSearchMode = ref<boolean>(false)
const searchKeyword = ref<string>('')
const skuPopupVisible = ref<boolean>(false)
const skuPopupLoading = ref<boolean>(false)
const skuPopupErrorMessage = ref<string>('')
const activePurchaseItem = ref<FavoritePurchaseItem | null>(null)
const popupSkuList = ref<Array<FavoriteSkuItem>>([])
const popupSelectedSkuId = ref<string>('')
const popupQuantity = ref<number>(1)
const totalItemsCount = computed((): number => {
let total = 0
for (let i = 0; i < groups.value.length; i++) {
total += groups.value[i].items.length
}
return total
})
const filteredGroups = computed((): Array<FavoriteGroup> => {
const keyword = searchKeyword.value.trim().toLowerCase()
if (keyword == '') {
return groups.value
}
const result = [] as Array<FavoriteGroup>
for (let i = 0; i < groups.value.length; i++) {
const group = groups.value[i]
const matchedItems = [] as Array<FavoritePurchaseItem>
for (let j = 0; j < group.items.length; j++) {
const item = group.items[j]
const titleMatch = item.title.toLowerCase().indexOf(keyword) >= 0
const merchantMatch = item.merchant_name.toLowerCase().indexOf(keyword) >= 0
const descMatch = item.description_text.toLowerCase().indexOf(keyword) >= 0
const skuMatch = item.selected_sku_text.toLowerCase().indexOf(keyword) >= 0
if (titleMatch || merchantMatch || descMatch || skuMatch) {
matchedItems.push(item)
}
}
if (matchedItems.length > 0) {
result.push({
group_id: group.group_id,
group_name: group.group_name,
group_logo_url: group.group_logo_url,
group_tags: group.group_tags,
items: matchedItems
} as FavoriteGroup)
}
}
return result
})
const visibleItemsCount = computed((): number => {
let total = 0
for (let i = 0; i < filteredGroups.value.length; i++) {
total += filteredGroups.value[i].items.length
}
return total
})
const purchasableProductItems = computed((): Array<FavoritePurchaseItem> => {
const result = [] as Array<FavoritePurchaseItem>
for (let i = 0; i < groups.value.length; i++) {
for (let j = 0; j < groups.value[i].items.length; j++) {
const item = groups.value[i].items[j]
if (item.target_type == 'product' && item.can_purchase == true) {
result.push(item)
}
}
}
return result
})
const readyPurchaseItems = computed((): Array<FavoritePurchaseItem> => {
const result = [] as Array<FavoritePurchaseItem>
for (let i = 0; i < purchasableProductItems.value.length; i++) {
const item = purchasableProductItems.value[i]
if (item.has_skus == false || item.selected_sku_id != '') {
result.push(item)
}
}
return result
})
const selectedPurchaseItems = computed((): Array<FavoritePurchaseItem> => {
const result = [] as Array<FavoritePurchaseItem>
for (let i = 0; i < readyPurchaseItems.value.length; i++) {
const item = readyPurchaseItems.value[i]
if (item.purchase_selected == true) {
result.push(item)
}
}
return result
})
const selectedPurchaseCount = computed((): number => {
let total = 0
for (let i = 0; i < selectedPurchaseItems.value.length; i++) {
total += selectedPurchaseItems.value[i].quantity
}
return total
})
const selectedPurchaseTotalAmount = computed((): number => {
let totalCents = 0
for (let i = 0; i < selectedPurchaseItems.value.length; i++) {
const item = selectedPurchaseItems.value[i]
totalCents += getItemUnitPriceCents(item) * item.quantity
}
return totalCents / 100
})
const isPurchaseAllSelected = computed((): boolean => {
return readyPurchaseItems.value.length > 0 && selectedPurchaseItems.value.length == readyPurchaseItems.value.length
})
const selectedManageItems = computed((): Array<FavoritePurchaseItem> => {
const result = [] as Array<FavoritePurchaseItem>
for (let i = 0; i < groups.value.length; i++) {
for (let j = 0; j < groups.value[i].items.length; j++) {
const item = groups.value[i].items[j]
if (item.manage_selected == true) {
result.push(item)
}
}
}
return result
})
const selectedManageCount = computed((): number => {
return selectedManageItems.value.length
})
const isManageAllSelected = computed((): boolean => {
return totalItemsCount.value > 0 && selectedManageCount.value == totalItemsCount.value
})
const favoritesContentClass = computed((): Array<string> => {
const classes = ['favorites-content'] as Array<string>
if (isEditMode.value) {
classes.push('favorites-content-manage')
} else if (totalItemsCount.value > 0) {
classes.push('favorites-content-purchase')
}
return classes
})
function toCents(value: number): number {
return Math.round(value * 100)
}
function formatPrice(value: number): string {
return value.toFixed(2)
}
function formatAmount(value: number): string {
return value.toFixed(2)
}
function getItemUnitPrice(item: FavoritePurchaseItem): number {
if (item.target_type != 'product') {
return 0
}
if (item.has_skus && item.selected_sku_id != '') {
return item.selected_sku_price
}
return item.current_price
}
function getItemUnitPriceCents(item: FavoritePurchaseItem): number {
return toCents(getItemUnitPrice(item))
}
function getDisplayPrice(item: FavoritePurchaseItem): number {
return getItemUnitPrice(item)
}
function getSelectedSkuSummary(item: FavoritePurchaseItem): string {
if (item.target_type != 'product') {
return ''
}
if (item.has_skus) {
return item.selected_sku_id != '' ? ('已选:' + item.selected_sku_text) : '请选择规格'
}
return '默认规格'
}
function getQuantityHint(item: FavoritePurchaseItem): string {
if (item.can_purchase == false) {
return item.availability_status != '' ? item.availability_status : '暂不可购买'
}
if (item.has_skus && item.selected_sku_id == '') {
return '选规格后可结算'
}
return '数量'
}
function clearManageSelections(): void {
for (let i = 0; i < groups.value.length; i++) {
for (let j = 0; j < groups.value[i].items.length; j++) {
groups.value[i].items[j].manage_selected = false
}
}
groups.value = [...groups.value]
}
async function loadFavoriteGroups(): Promise<void> {
isLoading.value = true
try {
const result = await supabaseService.getFavoriteGroups()
groups.value = result
clearManageSelections()
} catch (e) {
console.error('加载收藏列表失败', e)
groups.value = [] as Array<FavoriteGroup>
} finally {
isLoading.value = false
}
}
function handleBack(): void {
uni.navigateBack({
fail: () => {
uni.switchTab({ url: '/pages/main/index' })
}
})
}
function enterSearchMode(): void {
isEditMode.value = false
clearManageSelections()
isSearchMode.value = true
}
function exitSearchMode(): void {
isSearchMode.value = false
searchKeyword.value = ''
}
function clearSearchKeyword(): void {
searchKeyword.value = ''
}
function onSearchInput(e: any): void {
const detail = e.detail
if (detail != null && detail.value != null) {
searchKeyword.value = detail.value as string
}
}
function toggleManageMode(): void {
if (totalItemsCount.value == 0) {
return
}
if (isEditMode.value == false) {
isSearchMode.value = false
searchKeyword.value = ''
}
isEditMode.value = !isEditMode.value
clearManageSelections()
}
function handleItemClick(item: FavoritePurchaseItem): void {
if (isEditMode.value) {
toggleManageSelect(item)
return
}
openFavorite(item, false)
}
function toggleManageSelect(item: FavoritePurchaseItem): void {
item.manage_selected = item.manage_selected != true
groups.value = [...groups.value]
}
function toggleManageSelectAll(): void {
const nextState = !isManageAllSelected.value
for (let i = 0; i < groups.value.length; i++) {
for (let j = 0; j < groups.value[i].items.length; j++) {
groups.value[i].items[j].manage_selected = nextState
}
}
groups.value = [...groups.value]
}
function togglePurchaseSelect(item: FavoritePurchaseItem): void {
if (item.can_purchase == false) {
uni.showToast({ title: item.availability_status != '' ? item.availability_status : '当前商品不可购买', icon: 'none' })
return
}
if (item.has_skus && item.selected_sku_id == '') {
openSkuPopup(item)
return
}
item.purchase_selected = item.purchase_selected != true
groups.value = [...groups.value]
}
function togglePurchaseSelectAll(): void {
const nextState = !isPurchaseAllSelected.value
for (let i = 0; i < readyPurchaseItems.value.length; i++) {
readyPurchaseItems.value[i].purchase_selected = nextState
}
groups.value = [...groups.value]
}
function canDecreaseQuantity(item: FavoritePurchaseItem): boolean {
return isEditMode.value == false && item.target_type == 'product' && item.quantity > 1
}
function canIncreaseQuantity(item: FavoritePurchaseItem): boolean {
if (isEditMode.value || item.target_type != 'product' || item.can_purchase == false) {
return false
}
if (item.has_skus && item.selected_sku_id == '') {
return true
}
const maxStock = item.has_skus ? item.selected_sku_stock : item.selected_sku_stock
if (maxStock <= 0) {
return false
}
return item.quantity < maxStock
}
function decreaseItemQuantity(item: FavoritePurchaseItem): void {
if (item.quantity <= 1) {
return
}
item.quantity -= 1
item.purchase_selected = true
groups.value = [...groups.value]
}
function increaseItemQuantity(item: FavoritePurchaseItem): void {
if (item.can_purchase == false) {
uni.showToast({ title: item.availability_status != '' ? item.availability_status : '当前商品不可购买', icon: 'none' })
return
}
if (item.has_skus && item.selected_sku_id == '') {
openSkuPopup(item)
return
}
if (canIncreaseQuantity(item) == false) {
uni.showToast({ title: '库存不足', icon: 'none' })
return
}
item.quantity += 1
item.purchase_selected = true
groups.value = [...groups.value]
}
function collectSelectedFavoriteIds(): Array<string> {
const result = [] as Array<string>
for (let i = 0; i < selectedManageItems.value.length; i++) {
const favoriteId = selectedManageItems.value[i].favorite_id
if (favoriteId != '') {
result.push(favoriteId)
}
}
return result
}
async function executeDeleteFavorites(favoriteIds: Array<string>): Promise<void> {
if (favoriteIds.length == 0) {
uni.showToast({ title: '请选择要删除的内容', icon: 'none' })
return
}
uni.showLoading({ title: '删除中...' })
const success = await supabaseService.softDeleteFavorites(favoriteIds)
uni.hideLoading()
if (success == false) {
uni.showToast({ title: '删除失败,请稍后重试', icon: 'none' })
return
}
await loadFavoriteGroups()
if (totalItemsCount.value == 0) {
isEditMode.value = false
}
uni.showToast({ title: '删除成功', icon: 'success' })
}
function deleteSelected(): void {
const favoriteIds = collectSelectedFavoriteIds()
if (favoriteIds.length == 0) {
uni.showToast({ title: '请选择要删除的内容', icon: 'none' })
return
}
uni.showModal({
title: '删除收藏',
content: '确定删除选中的收藏内容吗?',
success: (res) => {
if (res.confirm) {
executeDeleteFavorites(favoriteIds)
}
}
})
}
function deleteSingleFavorite(item: FavoritePurchaseItem): void {
if (item.favorite_id == '') {
uni.showToast({ title: '收藏记录异常', icon: 'none' })
return
}
uni.showModal({
title: '删除收藏',
content: '确定删除该收藏内容吗?',
success: (res) => {
if (res.confirm) {
executeDeleteFavorites([item.favorite_id])
}
}
})
}
function openFavorite(item: FavoritePurchaseItem, bookingMode: boolean): void {
if (item.target_type == 'service') {
let url = '/pages/mall/consumer/home-service/service-detail?id=' + item.target_id
if (bookingMode) {
url += '&mode=booking'
}
uni.navigateTo({ url: url })
return
}
uni.navigateTo({ url: '/pages/mall/consumer/product-detail?id=' + item.target_id })
}
function normalizeSkuSpecText(rawValue: string): string {
if (rawValue == '') {
return '默认规格'
}
return rawValue
}
function convertProductSkuToFavoriteSku(productSku: ProductSku): FavoriteSkuItem {
let specText = ''
if (productSku.specifications != '') {
try {
const specs = JSON.parse(productSku.specifications) as UTSJSONObject
for (const key in specs) {
const value = specs[key]
if (value != null) {
specText += (specText == '' ? '' : ' | ') + value.toString()
}
}
} catch (e) {
console.error('解析收藏页SKU规格失败', e)
}
}
if (specText == '') {
specText = productSku.sku_code != '' ? productSku.sku_code : '默认规格'
}
return {
sku_id: productSku.id,
product_id: productSku.product_id,
sku_name: specText,
spec_text: specText,
sku_image_url: productSku.image_url ?? '',
sale_price: productSku.price,
stock_quantity: productSku.stock ?? 0,
status: productSku.status ?? 1
} as FavoriteSkuItem
}
function resetSkuPopupState(): void {
skuPopupLoading.value = false
skuPopupErrorMessage.value = ''
activePurchaseItem.value = null
popupSkuList.value = [] as Array<FavoriteSkuItem>
popupSelectedSkuId.value = ''
popupQuantity.value = 1
}
async function loadPopupSkus(item: FavoritePurchaseItem): Promise<void> {
skuPopupLoading.value = true
skuPopupErrorMessage.value = ''
try {
let skuList = item.sku_list
if (skuList.length == 0) {
const productSkus = await supabaseService.getProductSkus(item.target_id)
const mapped = [] as Array<FavoriteSkuItem>
for (let i = 0; i < productSkus.length; i++) {
mapped.push(convertProductSkuToFavoriteSku(productSkus[i]))
}
skuList = mapped
item.sku_list = mapped
}
popupSkuList.value = skuList
if (popupSkuList.value.length == 0) {
skuPopupErrorMessage.value = '暂无可选规格'
return
}
popupSelectedSkuId.value = item.selected_sku_id
if (popupSelectedSkuId.value == '') {
for (let i = 0; i < popupSkuList.value.length; i++) {
const sku = popupSkuList.value[i]
if (popupSkuDisabled(sku) == false) {
popupSelectedSkuId.value = sku.sku_id
if (item.selected_sku_id != '' || popupSkuList.value.length == 1) {
break
}
popupSelectedSkuId.value = ''
break
}
}
}
popupQuantity.value = item.quantity > 0 ? item.quantity : 1
} catch (e) {
console.error('加载收藏页SKU失败', e)
skuPopupErrorMessage.value = '规格加载失败,请重试'
} finally {
skuPopupLoading.value = false
}
}
async function openSkuPopup(item: FavoritePurchaseItem): Promise<void> {
if (item.target_type != 'product') {
return
}
activePurchaseItem.value = item
popupSkuList.value = [] as Array<FavoriteSkuItem>
popupSelectedSkuId.value = ''
popupQuantity.value = item.quantity > 0 ? item.quantity : 1
skuPopupVisible.value = true
await loadPopupSkus(item)
}
function retryPopupSkuLoad(): void {
if (activePurchaseItem.value == null) {
return
}
loadPopupSkus(activePurchaseItem.value)
}
function closeSkuPopup(): void {
skuPopupVisible.value = false
}
function onSkuPopupAfterLeave(): void {
if (skuPopupVisible.value == false) {
resetSkuPopupState()
}
}
function popupSkuDisabled(sku: FavoriteSkuItem): boolean {
return sku.status != 1 || sku.stock_quantity <= 0
}
function getPopupSkuClass(sku: FavoriteSkuItem): Array<string> {
const classes = ['sku-chip'] as Array<string>
if (popupSelectedSkuId.value == sku.sku_id) {
classes.push('sku-chip-active')
}
if (popupSkuDisabled(sku)) {
classes.push('sku-chip-disabled')
}
return classes
}
function selectPopupSku(sku: FavoriteSkuItem): void {
if (popupSkuDisabled(sku)) {
uni.showToast({ title: '该规格暂不可购买', icon: 'none' })
return
}
popupSelectedSkuId.value = sku.sku_id
if (popupQuantity.value > sku.stock_quantity) {
popupQuantity.value = sku.stock_quantity > 0 ? sku.stock_quantity : 1
}
if (popupQuantity.value < 1) {
popupQuantity.value = 1
}
}
function getPopupSelectedSku(): FavoriteSkuItem | null {
for (let i = 0; i < popupSkuList.value.length; i++) {
if (popupSkuList.value[i].sku_id == popupSelectedSkuId.value) {
return popupSkuList.value[i]
}
}
return null
}
function getPopupPrice(): number {
const selectedSku = getPopupSelectedSku()
if (selectedSku != null) {
return selectedSku.sale_price
}
return activePurchaseItem.value != null ? activePurchaseItem.value.current_price : 0
}
function getPopupStock(): number {
const selectedSku = getPopupSelectedSku()
if (selectedSku != null) {
return selectedSku.stock_quantity
}
return 0
}
function getPopupImage(): string {
const selectedSku = getPopupSelectedSku()
if (selectedSku != null && selectedSku.sku_image_url != '') {
return selectedSku.sku_image_url
}
return activePurchaseItem.value != null ? activePurchaseItem.value.image_url : '/static/images/default.png'
}
function getPopupSelectedSummary(): string {
const selectedSku = getPopupSelectedSku()
if (selectedSku != null) {
return selectedSku.spec_text + ' × ' + popupQuantity.value.toString()
}
return '请选择规格'
}
function decreasePopupQuantity(): void {
if (popupQuantity.value > 1) {
popupQuantity.value -= 1
}
}
function canIncreasePopupQuantity(): boolean {
const selectedSku = getPopupSelectedSku()
if (selectedSku == null) {
return false
}
return popupQuantity.value < selectedSku.stock_quantity
}
function increasePopupQuantity(): void {
const selectedSku = getPopupSelectedSku()
if (selectedSku == null) {
uni.showToast({ title: '请先选择规格', icon: 'none' })
return
}
if (popupQuantity.value >= selectedSku.stock_quantity) {
uni.showToast({ title: '库存不足', icon: 'none' })
return
}
popupQuantity.value += 1
}
function canConfirmPopupSelection(): boolean {
return skuPopupLoading.value == false && skuPopupErrorMessage.value == '' && getPopupSelectedSku() != null
}
function confirmPopupSelection(): void {
const item = activePurchaseItem.value
const selectedSku = getPopupSelectedSku()
if (item == null || selectedSku == null) {
uni.showToast({ title: '请选择有效规格', icon: 'none' })
return
}
if (popupQuantity.value > selectedSku.stock_quantity) {
uni.showToast({ title: '库存不足', icon: 'none' })
return
}
item.selected_sku_id = selectedSku.sku_id
item.selected_sku_text = normalizeSkuSpecText(selectedSku.spec_text)
item.selected_sku_price = selectedSku.sale_price
item.selected_sku_stock = selectedSku.stock_quantity
item.quantity = popupQuantity.value
item.purchase_selected = true
groups.value = [...groups.value]
closeSkuPopup()
uni.showToast({ title: '已更新规格', icon: 'success' })
}
async function goToCheckout(): Promise<void> {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId == '') {
goToLogin('/pages/mall/consumer/favorites')
return
}
if (selectedPurchaseItems.value.length == 0) {
uni.showToast({ title: '请选择可结算商品', icon: 'none' })
return
}
const checkoutItems = [] as any[]
for (let i = 0; i < selectedPurchaseItems.value.length; i++) {
const item = selectedPurchaseItems.value[i]
let skuSpecifications: any = {}
if (item.selected_sku_text != '') {
skuSpecifications = { spec: item.selected_sku_text } as any
}
checkoutItems.push({
id: item.selected_sku_id != '' ? item.selected_sku_id : item.target_id,
product_id: item.target_id,
sku_id: item.has_skus ? item.selected_sku_id : '',
product_name: item.title,
product_image: item.image_url,
sku_specifications: skuSpecifications,
price: formatPrice(getItemUnitPrice(item)),
quantity: item.quantity,
shop_id: item.merchant_id,
shop_name: item.merchant_name,
merchant_id: item.merchant_id
})
}
try {
uni.setStorageSync('checkout_type', 'buy_now')
uni.setStorageSync('checkout_items', JSON.stringify(checkoutItems))
} catch (e) {
console.error('收藏页写入结算数据失败', e)
uni.showToast({ title: '系统异常,请重试', icon: 'none' })
return
}
uni.navigateTo({ url: '/pages/mall/consumer/checkout' })
}
function openMoreActions(item: FavoritePurchaseItem): void {
const actionKeys = [] as Array<string>
const itemList = [] as Array<string>
if (item.target_type == 'service') {
itemList.push('查看服务')
actionKeys.push('view')
if (item.can_appoint == true) {
itemList.push('去预约')
actionKeys.push('book')
}
} else {
itemList.push('查看商品')
actionKeys.push('view')
if (item.can_purchase == true) {
itemList.push(item.has_skus ? '选择规格' : '加入结算')
actionKeys.push(item.has_skus ? 'spec' : 'purchase')
}
}
itemList.push('删除收藏')
actionKeys.push('delete')
uni.showActionSheet({
itemList: itemList,
success: (res) => {
const actionKey = actionKeys[res.tapIndex]
if (actionKey == 'view') {
openFavorite(item, false)
return
}
if (actionKey == 'book') {
openFavorite(item, true)
return
}
if (actionKey == 'spec') {
openSkuPopup(item)
return
}
if (actionKey == 'purchase') {
item.purchase_selected = true
groups.value = [...groups.value]
return
}
if (actionKey == 'delete') {
deleteSingleFavorite(item)
}
}
})
}
function handleImageError(item: FavoritePurchaseItem): void {
if (item.image_url == '/static/images/default.png') {
return
}
item.image_url = '/static/images/default.png'
groups.value = [...groups.value]
}
function getGroupInitial(name: string): string {
if (name == '') {
return '收'
}
return name.substring(0, 1)
}
function goShopping(): void {
uni.switchTab({ url: '/pages/main/index' })
}
onMounted(() => {
loadFavoriteGroups()
})
</script>
<style scoped>
.favorites-page {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
background-color: #f4f4f4;
}
.favorites-nav {
height: 48px;
padding: 0 10px;
background-color: #ffffff;
border-bottom: 1px solid #ededed;
display: flex;
flex-direction: row;
align-items: center;
}
.nav-left {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-icon {
font-size: 26px;
color: #222222;
line-height: 1;
}
.nav-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.nav-title {
font-size: 17px;
font-weight: 600;
color: #222222;
}
.nav-search-panel {
flex: 1;
padding-right: 10px;
}
.search-input-wrap {
height: 34px;
border-radius: 17px;
background-color: #f3f4f6;
padding: 0 12px;
display: flex;
flex-direction: row;
align-items: center;
}
.search-icon {
width: 16px;
height: 16px;
position: relative;
margin-right: 8px;
}
.search-icon-circle {
width: 10px;
height: 10px;
border: 1.5px solid #666666;
border-radius: 6px;
position: absolute;
top: 1px;
left: 1px;
}
.search-icon-handle {
width: 7px;
height: 1.5px;
background-color: #666666;
position: absolute;
right: 0;
bottom: 1px;
transform: rotate(45deg);
transform-origin: center;
}
.search-input {
flex: 1;
height: 34px;
font-size: 14px;
color: #222222;
}
.search-clear {
font-size: 18px;
color: #999999;
line-height: 1;
}
.nav-right {
min-width: 72px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.nav-actions-normal {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
}
.nav-search-trigger {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
}
.nav-search-icon-small {
margin-right: 0;
}
.nav-action-text {
font-size: 14px;
color: #222222;
}
.nav-action-disabled {
color: #b5b5b5;
}
.favorites-content {
flex: 1;
height: 0px;
padding: 0 0 calc(12px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.favorites-content-purchase {
padding-bottom: calc(74px + env(safe-area-inset-bottom));
}
.favorites-content-manage {
padding-bottom: calc(74px + env(safe-area-inset-bottom));
}
.state-box {
background-color: #ffffff;
margin-top: 12px;
padding: 52px 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-box {
margin-top: 12px;
}
.state-icon {
font-size: 28px;
color: #c3c3c3;
margin-bottom: 14px;
}
.state-title {
font-size: 16px;
color: #333333;
margin-bottom: 8px;
}
.state-subtitle {
font-size: 13px;
color: #999999;
margin-bottom: 20px;
text-align: center;
}
.go-shopping-btn {
background-color: #e1251b;
color: #ffffff;
border-radius: 18px;
padding: 10px 34px;
font-size: 14px;
border: none;
}
.group-list {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
}
.group-section {
background-color: #ffffff;
padding-left: 10px;
padding-right: 10px;
}
.group-header {
height: 42px;
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid #f2f2f2;
}
.group-logo-wrap,
.group-logo-fallback {
width: 26px;
height: 26px;
border-radius: 13px;
overflow: hidden;
margin-right: 8px;
background-color: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
}
.group-logo {
width: 26px;
height: 26px;
}
.group-logo-text {
font-size: 12px;
color: #666666;
font-weight: 600;
}
.group-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.group-name {
font-size: 14px;
color: #222222;
font-weight: 600;
}
.group-tag-row {
display: flex;
flex-direction: row;
gap: 4px;
margin-top: 2px;
}
.group-tag {
font-size: 10px;
color: #888888;
}
.group-arrow {
font-size: 16px;
color: #b5b5b5;
}
.favorite-row {
padding-top: 10px;
padding-bottom: 10px;
display: flex;
flex-direction: row;
align-items: stretch;
border-bottom: 1px solid #f4f4f4;
}
.favorite-row-last {
border-bottom-width: 0;
}
.selector-column {
width: 28px;
display: flex;
align-items: center;
justify-content: flex-start;
padding-top: 36px;
padding-right: 2px;
box-sizing: border-box;
}
.selector-placeholder {
width: 20px;
height: 20px;
}
.select-icon {
width: 20px;
height: 20px;
border: 1px solid #d0d0d0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background-color: #ffffff;
}
.select-icon.selected {
background-color: #e1251b;
border-color: #e1251b;
}
.select-icon.disabled {
background-color: #f3f3f3;
border-color: #e0e0e0;
}
.icon-text {
font-size: 12px;
color: #ffffff;
}
.favorite-image {
width: 96px;
height: 96px;
border-radius: 8px;
background-color: #f5f5f5;
margin-right: 10px;
flex-shrink: 0;
}
.favorite-main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 96px;
min-width: 0;
}
.favorite-title {
font-size: 14px;
line-height: 1.45;
color: #222222;
margin-bottom: 4px;
}
.tag-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 4px;
gap: 6px;
}
.tag-chip {
padding: 2px 6px;
border-radius: 10px;
background-color: #fff4f2;
color: #d94841;
font-size: 11px;
}
.favorite-desc {
font-size: 12px;
line-height: 1.45;
color: #777777;
margin-bottom: 4px;
}
.favorite-status {
font-size: 12px;
color: #999999;
margin-bottom: 4px;
}
.spec-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
gap: 8px;
}
.spec-label {
flex: 1;
font-size: 12px;
color: #999999;
line-height: 1.4;
}
.spec-action {
font-size: 12px;
color: #d94841;
flex-shrink: 0;
}
.favorite-footer {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
gap: 8px;
}
.price-wrap {
display: flex;
flex-direction: row;
align-items: baseline;
flex-shrink: 0;
}
.current-price {
font-size: 17px;
font-weight: 700;
color: #e1251b;
margin-right: 6px;
}
.original-price {
font-size: 11px;
color: #b5b5b5;
text-decoration: line-through;
}
.purchase-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.quantity-tip {
font-size: 11px;
color: #999999;
}
.quantity-stepper,
.popup-stepper {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.step-btn {
width: 22px;
height: 22px;
border-radius: 11px;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
}
.step-btn.disabled {
background-color: #f0f0f0;
opacity: 0.5;
}
.step-text {
font-size: 14px;
color: #333333;
line-height: 1;
}
.quantity-value,
.popup-quantity-value {
font-size: 13px;
color: #222222;
min-width: 18px;
text-align: center;
}
.service-action-btn {
min-width: 68px;
height: 30px;
padding: 0 14px;
border-radius: 15px;
border: none;
background-color: #fff2ef;
color: #d94841;
font-size: 12px;
line-height: 30px;
}
.more-column {
width: 24px;
margin-left: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
}
.more-dot {
width: 4px;
height: 4px;
border-radius: 2px;
background-color: #a8a8a8;
}
.purchase-bar,
.manage-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
background-color: #ffffff;
border-top: 1px solid #ededed;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.bottom-left {
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
}
.bottom-text {
font-size: 14px;
color: #222222;
margin-left: 8px;
}
.purchase-total-box {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
min-width: 0;
}
.purchase-count-text {
font-size: 12px;
color: #666666;
margin-bottom: 2px;
}
.purchase-total-text {
font-size: 13px;
color: #333333;
}
.purchase-total-amount {
color: #e1251b;
font-weight: 700;
}
.checkout-btn,
.delete-btn,
.sku-confirm-btn {
min-width: 106px;
height: 38px;
padding: 0 18px;
border-radius: 19px;
background-color: #e1251b;
color: #ffffff;
font-size: 14px;
line-height: 38px;
border: none;
flex-shrink: 0;
}
.checkout-btn-disabled,
.delete-btn-disabled,
.sku-confirm-btn-disabled {
background-color: #f3b9b5;
color: #ffffff;
}
.sku-popup {
background-color: #ffffff;
border-top-left-radius: 18px;
border-top-right-radius: 18px;
max-height: 76vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sku-popup-header {
padding: 16px 16px 12px;
display: flex;
flex-direction: row;
align-items: flex-start;
border-bottom: 1px solid #f1f1f1;
position: relative;
}
.sku-popup-image {
width: 88px;
height: 88px;
border-radius: 10px;
background-color: #f5f5f5;
margin-right: 12px;
flex-shrink: 0;
}
.sku-popup-summary {
flex: 1;
display: flex;
flex-direction: column;
padding-top: 4px;
min-width: 0;
}
.sku-popup-price {
font-size: 20px;
font-weight: 700;
color: #e1251b;
margin-bottom: 8px;
}
.popup-tag-row {
margin-bottom: 8px;
}
.sku-popup-selected {
font-size: 12px;
line-height: 1.5;
color: #666666;
}
.sku-popup-close {
position: absolute;
top: 14px;
right: 16px;
font-size: 20px;
line-height: 1;
color: #999999;
}
.sku-popup-state {
padding: 30px 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.sku-popup-state-text {
font-size: 13px;
color: #666666;
}
.sku-popup-retry {
font-size: 13px;
color: #d94841;
}
.sku-popup-scroll {
flex: 1;
height: 0px;
padding: 0 16px;
box-sizing: border-box;
}
.sku-section {
padding-top: 16px;
padding-bottom: 8px;
}
.quantity-section {
padding-bottom: 18px;
}
.sku-section-title {
font-size: 14px;
font-weight: 600;
color: #222222;
margin-bottom: 12px;
display: block;
}
.sku-chip-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
}
.sku-chip {
padding: 8px 12px;
border-radius: 18px;
background-color: #f5f5f5;
border: 1px solid #f5f5f5;
}
.sku-chip-active {
background-color: #fff4f2;
border-color: #f1b2ad;
}
.sku-chip-disabled {
background-color: #f2f2f2;
border-color: #f2f2f2;
}
.sku-chip-text {
font-size: 13px;
color: #333333;
}
.sku-chip-text-disabled {
color: #b5b5b5;
}
.popup-stock-text {
font-size: 12px;
color: #999999;
margin-top: 8px;
display: block;
}
.sku-popup-footer {
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid #f1f1f1;
background-color: #ffffff;
}
.sku-confirm-btn {
width: 100%;
border-radius: 22px;
}
@media (min-width: 768px) {
.favorites-content {
max-width: 960px;
margin: 0 auto;
width: 100%;
}
.purchase-bar,
.manage-bar {
left: 50%;
transform: translateX(-50%);
max-width: 960px;
}
}
</style>