Merge remote-tracking branch 'origin/huangzhenbao-admin'

This commit is contained in:
not-like-juvenile
2026-03-18 17:14:05 +08:00
676 changed files with 25158 additions and 46646 deletions

View File

@@ -0,0 +1,205 @@
<template>
<view class="member-price-page">
<view class="page-header">
<view class="back-link" @click="goBack">
<text class="arrow">{"<"}</text>
<text class="back-txt">返回</text>
</view>
<text class="header-title">会员价管理</text>
</view>
<view class="content-card">
<view v-if="isLoading" class="loading-box">
<text>加载中...</text>
</view>
<template v-else>
<view class="product-info">
<image class="p-img" :src="productImage || '/static/logo.png'" mode="aspectFill" />
<text class="p-name">{{ productName }}</text>
</view>
<view class="price-table">
<view class="th-row">
<view class="th flex-2"><text>规格名称</text></view>
<view class="th flex-1"><text>售价</text></view>
<view v-for="level in levels" :key="level.id" class="th flex-1">
<text>{{ level.name }}</text>
</view>
</view>
<view v-if="skus.length === 0" class="empty-table">
<text>该商品暂无规格信息</text>
</view>
<view v-for="(sku, sIdx) in skus" :key="sku.id" class="tr-row">
<view class="td flex-2"><text>{{ formatSpecs(sku.specifications) }}</text></view>
<view class="td flex-1"><text>¥{{ sku.price.toFixed(2) }}</text></view>
<view v-for="level in levels" :key="level.id" class="td flex-1">
<input
class="price-input"
type="digit"
v-model="priceMatrix[sku.id][level.id]"
:placeholder="calcDefaultPrice(sku.price, level.discount_percent)"
/>
</view>
</view>
</view>
<view class="footer-btns">
<button class="btn-save" :disabled="isSaving" @click="handleSave">保存设置</button>
</view>
</template>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, reactive } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import {
fetchActiveUserLevels,
fetchProductSkus,
fetchMemberPrices,
saveMemberPrices,
UserLevel,
ProductSku
} from '@/services/admin/productMemberPriceService.uts'
// --- State ---
const productId = ref<string>('')
const productName = ref<string>('加载中...')
const productImage = ref<string>('')
const levels = ref<UserLevel[]>([])
const skus = ref<ProductSku[]>([])
const priceMatrix = reactive<Record<string, Record<string, string>>>({})
const isLoading = ref(true)
const isSaving = ref(false)
onMounted(() => {
// 从路由参数获取 productId
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options as Record<string, string | undefined>
productId.value = options['id'] ?? ''
if (!productId.value) {
uni.showToast({ title: '未找到商品ID', icon: 'none' })
return
}
initData()
})
async function initData() {
isLoading.value = true
try {
// 并行获取等级、SKU和已设价格
const [levelRes, skuRes, priceRes] = await Promise.all([
fetchActiveUserLevels(),
fetchProductSkus(productId.value),
fetchMemberPrices(productId.value)
])
levels.value = levelRes
skus.value = skuRes
// 初始化矩阵并填充已设价格
skuRes.forEach(sku => {
priceMatrix[sku.id] = {}
levelRes.forEach(level => {
// 查找是否已有定价
const existing = priceRes.find(p => p.sku_id === sku.id && p.level_id === level.id)
priceMatrix[sku.id][level.id] = existing != null ? String(existing.member_price) : ''
})
})
// 如果有 SKU拿第一个的信息展示在头部
if (skuRes.length > 0) {
// 实际开发中建议单独查一次 Product 信息,这里简化处理
productImage.value = skuRes[0].image_url ?? ''
}
} catch (e) {
uni.showToast({ title: '加载数据失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function formatSpecs(specs: any): string {
if (specs == null) return '默认规格'
// 假设规格存的是对象 { "颜色": "红色", "尺寸": "XL" }
if (typeof specs === 'object') {
const vals = Object.values(specs as Record<string, any>)
return vals.join(',')
}
return String(specs)
}
function calcDefaultPrice(basePrice: number, discount: number): string {
return (basePrice * discount / 100).toFixed(2)
}
async function handleSave() {
isSaving.value = true
try {
const saveRows: Array<{ sku_id: string; level_id: string; member_price: number }> = []
// 遍历矩阵,只保存有输入数值的项
Object.keys(priceMatrix).forEach(skuId => {
const levelPrices = priceMatrix[skuId]
Object.keys(levelPrices).forEach(levelId => {
const val = levelPrices[levelId]
if (val != null && val.trim() !== '') {
saveRows.push({
sku_id: skuId,
level_id: levelId,
member_price: parseFloat(val)
})
}
})
})
const ok = await saveMemberPrices(productId.value, saveRows)
if (ok) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '保存异常', icon: 'none' })
} finally {
isSaving.value = false
}
}
function goBack() {
openRoute('product_productList')
}
</script>
<style scoped lang="scss">
.member-price-page { padding: 0; background: transparent; min-height: auto; }
.page-header { display: flex; flex-direction: row; align-items: center; gap: 16px; margin-bottom: 20px;
.back-link { display: flex; flex-direction: row; align-items: center; gap: 4px; color: #666; cursor: pointer; }
.header-title { font-size: 16px; font-weight: bold; color: #333; }
}
.content-card { background: #fff; border-radius: 4px; padding: 24px; min-height: 400px; }
.loading-box, .empty-table { padding: 60px 0; text-align: center; color: #999; font-size: 14px; }
.product-info { display: flex; flex-direction: row; align-items: center; gap: 16px; margin-bottom: 30px;
.p-img { width: 64px; height: 64px; border-radius: 4px; background: #f5f5f5; }
.p-name { font-size: 15px; font-weight: bold; color: #333; flex: 1; }
}
.price-table { border: 1px solid #f0f0f0; border-radius: 4px; overflow-x: auto; }
.th-row { display: flex; flex-direction: row; background: #f8f9fa; border-bottom: 1px solid #f0f0f0; min-width: 800px; }
.th { padding: 12px; font-size: 14px; font-weight: bold; color: #333; text-align: center; }
.tr-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-width: 800px; &:last-child { border-bottom: none; } }
.td { padding: 16px 12px; font-size: 14px; color: #666; text-align: center; display: flex; align-items: center; justify-content: center; }
.flex-1 { flex: 1; min-width: 120px; } .flex-2 { flex: 2; min-width: 200px; }
.price-input { border: 1px solid #dcdfe6; border-radius: 4px; height: 32px; padding: 0 8px; text-align: center; width: 90%; font-size: 13px; background: #fff; }
.footer-btns { margin-top: 40px; display: flex; justify-content: center; .btn-save { background: #1890ff; color: #fff; border: none; padding: 0 32px; height: 40px; border-radius: 4px; font-size: 14px;
&[disabled] { opacity: 0.6; }
} }
</style>