Files
hfb_sys/frontend/src/features/seller/views/MobileSellerListingsView.vue
T
yml2213 e241607535 支持号主与租客查看自己的发布/订单详情
新增卖家商品详情页;订单详情底部增加可折叠账号快照,已完成订单展示结账明细。
2026-07-16 20:47:33 +08:00

620 lines
14 KiB
Vue
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.
<script setup lang="ts">
import { showDialog, showToast } from 'vant'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
fetchSellerListings,
offlineListing,
submitListingReview,
type Listing,
} from '@/features/listings'
import { formatCent, formatMoneyWithSymbol } from '@/shared/utils/money'
import { listingReviewStatusLabel, listingStatusLabel } from '@/shared/utils/statusLabels'
import { formatListingCode, getListingSellerPrice } from '@/shared/utils/listingDisplay'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
const router = useRouter()
const loading = ref(false)
const submittingID = ref<number | null>(null)
const offliningID = ref<number | null>(null)
const listings = ref<Listing[]>([])
const activeTab = ref('all')
const statusTabs = computed(() => [
{ key: 'all', label: '全部', value: listings.value.length },
{
key: 'published',
label: '已上架',
value: listings.value.filter(item => item.status === 'published').length,
},
{
key: 'pending',
label: '待审核',
value: listings.value.filter(item => isPendingReview(item)).length,
},
{
key: 'offline',
label: '已下架',
value: listings.value.filter(item => item.status === 'offline').length,
},
{
key: 'completed',
label: '已完成',
value: listings.value.filter(item => item.status === 'completed').length,
},
])
const displayListings = computed(() => {
if (activeTab.value === 'all') return listings.value
if (activeTab.value === 'pending') return listings.value.filter(item => isPendingReview(item))
return listings.value.filter(item => item.status === activeTab.value)
})
onMounted(loadListings)
async function loadListings() {
loading.value = true
try {
listings.value = await fetchSellerListings()
} catch {
showToast({ message: '加载失败,请稍后重试', icon: 'warning-o' })
} finally {
loading.value = false
}
}
async function submitReview(row: Listing) {
submittingID.value = row.id
try {
const listing = await submitListingReview(row.id)
replaceListing(listing)
showToast({
message:
listing.status === 'published' && listing.review_status === 'approved'
? '已上架'
: '已提交审核,等待后台处理',
icon: 'passed',
})
} catch {
showToast({ message: '提交失败,请稍后重试', icon: 'cross' })
} finally {
submittingID.value = null
}
}
async function offline(row: Listing) {
try {
await showDialog({
title: '下架确认',
message: `确认下架「${row.title}」吗?下架后后台待审列表会同步移除。`,
showCancelButton: true,
confirmButtonText: '确认下架',
cancelButtonText: '取消',
})
} catch {
return
}
offliningID.value = row.id
try {
const listing = await offlineListing(row.id)
replaceListing(listing)
showToast({ message: '已下架', icon: 'passed' })
} catch {
showToast({ message: '下架失败,请稍后重试', icon: 'cross' })
} finally {
offliningID.value = null
}
}
function replaceListing(next: Listing) {
const index = listings.value.findIndex(item => item.id === next.id)
if (index >= 0) {
listings.value.splice(index, 1, next)
} else {
listings.value.unshift(next)
}
}
function listingPrice(row: Listing) {
return formatMoneyWithSymbol(getListingSellerPrice(row))
}
async function copyListingCode(row: Listing) {
try {
await navigator.clipboard.writeText(formatListingCode(row))
showToast({ message: '商品编号已复制', icon: 'passed' })
} catch {
showToast({ message: '复制失败', icon: 'cross' })
}
}
function coinText(row: Listing) {
const coinM = Number(row.haf_coin_amount || 0) / 1000000
return Number.isInteger(coinM) ? `${coinM}M` : `${coinM.toFixed(2)}M`
}
function goCreate() {
router.push('/m/seller/listings/create')
}
function goEdit(row: Listing) {
router.push(`/m/seller/listings/${row.id}/edit`)
}
function goDetail(row: Listing) {
router.push(`/m/seller/listings/${row.id}`)
}
function canSubmit(row: Listing) {
return !isTerminalListing(row) && !isPendingReview(row) && row.status !== 'published'
}
function canEdit(row: Listing) {
return !isTerminalListing(row) && !isPendingReview(row) && row.status !== 'published'
}
function canOffline(row: Listing) {
return !isTerminalListing(row) && row.status !== 'offline'
}
function statusTone(status: string) {
const tones: Record<string, string> = {
published: 'success',
draft: 'info',
offline: 'muted',
completed: 'success',
rented: 'warning',
abnormal: 'danger',
sealed: 'muted',
}
return tones[status] || 'info'
}
function reviewTone(status: string) {
const tones: Record<string, string> = {
approved: 'success',
pending: 'warning',
rejected: 'danger',
none: 'muted',
}
return tones[status] || 'info'
}
function effectiveReviewStatus(row: Listing) {
return row.status === 'offline' ? 'none' : row.review_status
}
function showReviewStatus(row: Listing) {
return row.status !== 'completed'
}
function isTerminalListing(row: Listing) {
return row.status === 'rented' || row.status === 'completed'
}
function isPendingReview(row: Listing) {
return !isTerminalListing(row) && row.status !== 'offline' && row.review_status === 'pending'
}
</script>
<template>
<main class="mobile-seller-listings">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>我的商品</h1>
<button class="refresh-btn" :class="{ spinning: loading }" @click="loadListings">
<van-icon name="replay" :size="18" />
</button>
</header>
<!-- 状态筛选条 -->
<van-tabs
v-model:active="activeTab"
class="custom-tabs"
line-width="20px"
line-height="3px"
color="#ff6a00"
title-active-color="#ff6a00"
title-inactive-color="#6b7280"
:border="false"
swipeable
animated
>
<van-tab
v-for="tab in statusTabs"
:key="tab.key"
:name="tab.key"
:title="tab.value > 0 ? `${tab.label} ${tab.value}` : tab.label"
/>
</van-tabs>
<!-- 商品列表 -->
<section class="listing-list">
<van-loading v-if="loading" class="center-loading" size="24px" vertical
>加载中...</van-loading
>
<div v-else-if="displayListings.length === 0" class="empty-state-wrap">
<van-empty description="暂无相关商品" image="search" />
<van-button round type="primary" class="empty-publish-btn" @click="goCreate">
去发布账号
</van-button>
</div>
<div v-else v-for="item in displayListings" :key="item.id" class="listing-card">
<!-- 卡片头编号 + 状态 -->
<div class="card-header">
<button class="listing-code-chip" type="button" @click.stop="copyListingCode(item)">
编号 {{ formatListingCode(item) }}
<van-icon name="description" :size="12" />
</button>
<div class="status-tags">
<span class="status-badge" :class="statusTone(item.status)">
{{ listingStatusLabel(item.status) }}
</span>
<span
v-if="showReviewStatus(item)"
class="status-badge"
:class="reviewTone(effectiveReviewStatus(item))"
>
{{ listingReviewStatusLabel(effectiveReviewStatus(item)) }}
</span>
</div>
</div>
<!-- 卡片体点击查看完整发布信息仅本人 -->
<button type="button" class="card-body-btn" @click="goDetail(item)">
<h3 class="listing-title">{{ item.title }}</h3>
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">区服</span>
<span class="meta-val">{{ item.server_region || '-' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">哈夫币</span>
<span class="meta-val">{{ coinText(item) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">价格</span>
<span class="meta-val price">{{ listingPrice(item) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">押金</span>
<span class="meta-val">¥{{ formatCent(item.deposit_amount_cent) }}</span>
</div>
</div>
<p v-if="item.review_reason" class="review-reason">
<van-icon name="info-o" :size="13" />
{{ item.review_reason }}
</p>
<span class="detail-hint">查看完整信息 </span>
</button>
<!-- 卡片底操作 -->
<div class="card-footer">
<van-button
size="small"
plain
round
class="action-btn"
@click="goDetail(item)"
>
详情
</van-button>
<van-button
v-if="canEdit(item)"
size="small"
plain
round
icon="edit"
class="action-btn"
@click="goEdit(item)"
>
编辑
</van-button>
<van-button
v-if="canSubmit(item)"
size="small"
plain
round
type="primary"
class="action-btn"
:loading="submittingID === item.id"
@click="submitReview(item)"
>
提审
</van-button>
<van-button
v-if="canOffline(item)"
size="small"
plain
round
type="danger"
class="action-btn"
:loading="offliningID === item.id"
@click="offline(item)"
>
下架
</van-button>
</div>
</div>
</section>
<MobileBottomNav />
</main>
</template>
<style scoped>
.mobile-seller-listings {
min-height: 100dvh;
background: #f6f8fa;
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
/* ========== 顶部导航 ========== */
.page-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.8);
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
color: #111827;
}
.back-btn,
.refresh-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #374151;
cursor: pointer;
}
.refresh-btn.spinning :deep(.van-icon) {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ========== Vant Tabs ========== */
.custom-tabs {
position: sticky;
top: 48px;
z-index: 99;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.6);
}
:deep(.van-tabs__nav) {
background: transparent;
padding-bottom: 4px;
}
:deep(.van-tab) {
font-size: 13px;
font-weight: 600;
}
/* ========== 列表 ========== */
.listing-list {
padding: 14px 16px;
}
.center-loading {
display: flex;
justify-content: center;
padding: 60px 0;
}
.empty-state-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0;
}
.empty-publish-btn {
margin-top: 4px;
padding: 0 28px;
background: linear-gradient(135deg, #ff8c00, #ff5f00);
border: none;
font-weight: 700;
}
/* ========== 商品卡片 ========== */
.listing-card {
background: #ffffff;
border-radius: 16px;
margin-bottom: 14px;
padding: 16px;
box-shadow:
0 4px 18px rgba(0, 0, 0, 0.02),
0 1px 4px rgba(0, 0, 0, 0.02);
border: 1px solid rgba(243, 244, 246, 0.9);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.listing-code-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 9px;
border: 1px solid #dbeafe;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.status-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
line-height: 1;
background: #eef2f7;
color: #64748b;
}
.status-badge.success {
background: rgba(16, 185, 129, 0.1);
color: #15803d;
}
.status-badge.warning {
background: rgba(217, 119, 6, 0.1);
color: #c2410c;
}
.status-badge.danger {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.status-badge.info {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
}
.status-badge.muted {
background: #f1f5f9;
color: #64748b;
}
.card-body-btn {
display: block;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
color: inherit;
}
.listing-title {
margin: 12px 0 0;
font-size: 15px;
font-weight: 700;
color: #111827;
line-height: 1.45;
}
.detail-hint {
display: inline-block;
margin-top: 10px;
color: #ff6a00;
font-size: 12px;
font-weight: 600;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 12px;
padding: 12px 14px;
background: #f9fafb;
border-radius: 12px;
}
.meta-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.meta-label {
font-size: 12px;
color: #9ca3af;
}
.meta-val {
font-size: 13px;
font-weight: 700;
color: #374151;
}
.meta-val.price {
color: #ff5f00;
font-size: 15px;
font-weight: 800;
}
.review-reason {
display: flex;
align-items: flex-start;
gap: 5px;
margin: 12px 0 0;
padding: 9px 12px;
border-radius: 10px;
background: rgba(217, 119, 6, 0.08);
color: #b45309;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
}
.card-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.action-btn {
height: 32px !important;
padding: 0 16px !important;
font-size: 13px !important;
font-weight: 700 !important;
}
</style>