新增摸大红业务并修复后台链接按钮白字

支持分类筛选、搜索、下单支付建群与固定二维码;合并迁移种子数据;后台表格编辑/详情链接恢复主题色可见。
This commit is contained in:
yml2213
2026-07-16 18:08:18 +08:00
parent 97a34e0329
commit 6d61027318
49 changed files with 9533 additions and 20 deletions
@@ -0,0 +1,406 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search, Tickets } from '@element-plus/icons-vue'
import MohongProductCard from '@/features/mohong/components/MohongProductCard.vue'
import {
fetchMohongCategories,
fetchMohongProducts,
type MohongCategory,
type MohongProduct,
} from '@/features/mohong/api/mohong'
import { readError } from '@/shared/utils/error'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const loadingMore = ref(false)
const products = ref<MohongProduct[]>([])
const categories = ref<MohongCategory[]>([])
const total = ref(0)
const page = ref(1)
const hasMore = ref(true)
const keyword = ref('')
const activeCategoryId = ref<number | null>(null)
let searchTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
await loadCategories()
applyRouteQuery()
await loadProducts(true)
})
watch(
() => route.query,
() => {
applyRouteQuery()
loadProducts(true)
}
)
function applyRouteQuery() {
const q = route.query
keyword.value = typeof q.keyword === 'string' ? q.keyword : ''
const cat = Number(q.category_id || 0)
activeCategoryId.value = cat > 0 ? cat : null
}
async function loadCategories() {
try {
categories.value = await fetchMohongCategories()
// 默认选中「大红」或第一个有商品的分类
if (!activeCategoryId.value && categories.value.length) {
const dahong = categories.value.find(c => c.name === '大红')
const first = categories.value.find(c => c.product_count > 0) || categories.value[0]
if (dahong || first) {
activeCategoryId.value = (dahong || first)!.id
syncQuery()
}
}
} catch (error) {
ElMessage.error(readError(error, '加载分类失败'))
}
}
async function loadProducts(reset = true) {
if (loadingMore.value) return
if (reset) {
loading.value = true
page.value = 1
hasMore.value = true
} else {
if (!hasMore.value) return
loadingMore.value = true
}
try {
const result = await fetchMohongProducts({
page: page.value,
page_size: 24,
keyword: keyword.value.trim() || undefined,
category_id: activeCategoryId.value || undefined,
})
const items = result.items || []
products.value = reset ? items : [...products.value, ...items]
total.value = result.total || 0
hasMore.value = products.value.length < total.value
page.value += 1
} catch (error) {
ElMessage.error(readError(error, '加载商品失败'))
} finally {
loading.value = false
loadingMore.value = false
}
}
function selectCategory(id: number | null) {
activeCategoryId.value = id
syncQuery()
loadProducts(true)
}
function onKeywordInput() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
syncQuery()
loadProducts(true)
}, 280)
}
function syncQuery() {
const query: Record<string, string> = {}
if (keyword.value.trim()) query.keyword = keyword.value.trim()
if (activeCategoryId.value) query.category_id = String(activeCategoryId.value)
router.replace({ path: '/mohong', query })
}
const activeCategoryName = () => {
if (!activeCategoryId.value) return '全部'
return categories.value.find(c => c.id === activeCategoryId.value)?.name || '全部'
}
</script>
<template>
<section class="mohong-pc-page">
<header class="toolbar">
<div class="title-block">
<h1>摸大红</h1>
<span class="count">{{ total }} </span>
<span class="tip">{{ activeCategoryName() }} · 支付后自动进群</span>
</div>
<div class="actions">
<div class="search-box">
<el-icon><Search /></el-icon>
<input
v-model="keyword"
type="search"
placeholder="搜索商品名称"
@input="onKeywordInput"
/>
</div>
<el-button size="small" :icon="Tickets" @click="router.push('/mohong/orders')">
我的订单
</el-button>
<el-button size="small" plain @click="router.push('/')">租号大厅</el-button>
</div>
</header>
<div class="shop-layout">
<aside class="cat-sidebar">
<button
type="button"
class="cat-item"
:class="{ active: !activeCategoryId }"
@click="selectCategory(null)"
>
全部
</button>
<button
v-for="cat in categories"
:key="cat.id"
type="button"
class="cat-item"
:class="{ active: activeCategoryId === cat.id }"
@click="selectCategory(cat.id)"
>
<span>{{ cat.name }}</span>
<em v-if="cat.product_count">{{ cat.product_count }}</em>
</button>
</aside>
<div class="shop-main">
<div v-if="!loading && products.length === 0" class="empty-state">
<strong>暂无商品</strong>
<span>试试切换分类或清空搜索关键词</span>
</div>
<div v-else v-loading="loading" class="product-grid">
<MohongProductCard v-for="item in products" :key="item.id" :product="item" />
</div>
<div v-if="!loading && products.length" class="load-state">
<el-button v-if="hasMore" :loading="loadingMore" @click="loadProducts(false)">
加载更多
</el-button>
<span v-else class="end">已经到底了</span>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.mohong-pc-page {
display: grid;
gap: 14px;
width: 100%;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 8px 0;
}
.title-block {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px 12px;
min-width: 0;
}
.title-block h1 {
margin: 0;
color: #17233d;
font-size: 20px;
font-weight: 800;
}
.count {
padding: 1px 8px;
border-radius: 999px;
background: #fff4ea;
color: #ff6a00;
font-size: 12px;
font-weight: 700;
}
.tip {
color: #8a94a6;
font-size: 12px;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.search-box {
display: flex;
align-items: center;
gap: 6px;
width: 220px;
height: 32px;
padding: 0 10px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
color: #94a3b8;
}
.search-box input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: #17233d;
font-size: 13px;
}
.shop-layout {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 16px;
align-items: start;
min-height: 480px;
}
.cat-sidebar {
position: sticky;
top: 12px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
border-radius: 12px;
background: #fff;
border: 1px solid #eef1f5;
overflow: hidden;
}
.cat-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 12px 14px;
border: 0;
border-left: 3px solid transparent;
background: transparent;
color: #52616f;
font-size: 14px;
text-align: left;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.cat-item:hover {
background: #f8fafc;
color: #17233d;
}
.cat-item.active {
background: #fff7ed;
border-left-color: #ff6a00;
color: #ff6a00;
font-weight: 700;
}
.cat-item em {
font-style: normal;
color: #b0b8c4;
font-size: 12px;
font-weight: 600;
}
.cat-item.active em {
color: #ff9a4d;
}
.shop-main {
min-width: 0;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
width: 100%;
}
.product-grid :deep(.mohong-card) {
justify-self: center;
}
.empty-state {
display: grid;
gap: 6px;
justify-items: center;
width: 100%;
padding: 64px 20px;
border-radius: 14px;
border: 1px dashed #dbe3ee;
background: #fff;
color: #8a94a6;
}
.empty-state strong {
color: #17233d;
}
.load-state {
display: flex;
justify-content: center;
width: 100%;
padding: 12px 0 8px;
}
.end {
color: #b0b8c4;
font-size: 12px;
}
@media (max-width: 900px) {
.shop-layout {
grid-template-columns: 1fr;
}
.cat-sidebar {
position: static;
flex-direction: row;
overflow-x: auto;
padding: 6px;
gap: 4px;
}
.cat-item {
flex: 0 0 auto;
border-left: 0;
border-radius: 8px;
padding: 8px 12px;
white-space: nowrap;
}
.cat-item.active {
border-left-color: transparent;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box {
width: 100%;
}
}
</style>