Files
hfb_sys/frontend/src/features/seller/views/SellerListingsView.vue
T
2026-06-08 20:00:07 +08:00

530 lines
12 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 { CopyDocument, Plus, Refresh } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import {
fetchSellerListings,
offlineListing,
submitListingReview,
type Listing,
} from '@/features/listings'
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
import { formatListingCode, getListingSellerPrice } from '@/utils/listingDisplay'
const loading = ref(false)
const submittingID = ref<number | null>(null)
const offliningID = ref<number | null>(null)
const listings = ref<Listing[]>([])
const statusFilter = ref('all')
const displayListings = computed(() => {
if (statusFilter.value === 'all') return listings.value
if (statusFilter.value === 'pending') return listings.value.filter(item => isPendingReview(item))
return listings.value.filter(item => item.status === statusFilter.value)
})
const stats = 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,
},
])
onMounted(loadListings)
async function loadListings() {
loading.value = true
try {
listings.value = await fetchSellerListings()
} finally {
loading.value = false
}
}
async function submitReview(row: Listing) {
submittingID.value = row.id
try {
const listing = await submitListingReview(row.id)
replaceListing(listing)
ElMessage.success(
listing.status === 'published' && listing.review_status === 'approved'
? '已上架'
: '已提交审核,等待后台处理'
)
} finally {
submittingID.value = null
}
}
async function offline(row: Listing) {
try {
await ElMessageBox.confirm(
`确认下架「${row.title}」吗?下架后后台待审列表会同步移除。`,
'下架确认',
{
confirmButtonText: '确认下架',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
offliningID.value = row.id
try {
const listing = await offlineListing(row.id)
replaceListing(listing)
ElMessage.success('已下架')
} 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 ${Math.round(getListingSellerPrice(row))}`
}
async function copyListingCode(row: Listing) {
try {
await navigator.clipboard.writeText(formatListingCode(row))
ElMessage.success('商品编号已复制')
} catch {
ElMessage.error('复制失败')
}
}
function coinText(row: Listing) {
const coinM = Number(row.haf_coin_amount || 0) / 1000000
return Number.isInteger(coinM) ? `${coinM}M` : `${coinM.toFixed(2)}M`
}
function canSubmit(row: Listing) {
return !isPendingReview(row) && row.status !== 'rented' && row.status !== 'published'
}
function canOffline(row: Listing) {
return row.status !== 'rented' && row.status !== 'offline'
}
function statusTone(status: string) {
const tones: Record<string, string> = {
published: 'success',
draft: 'info',
offline: 'muted',
rented: 'warning',
abnormal: 'danger',
}
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 isPendingReview(row: Listing) {
return row.status !== 'offline' && row.review_status === 'pending'
}
</script>
<template>
<section class="page seller-listings-page">
<div class="seller-hero">
<div>
<p class="eyebrow">Seller</p>
<h1>我的发布</h1>
<p>查看账号状态审核进度并管理上架与下架</p>
</div>
<div class="hero-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadListings">刷新</el-button>
<RouterLink to="/seller/listings/create">
<el-button type="primary" :icon="Plus">发布账号</el-button>
</RouterLink>
</div>
</div>
<div class="seller-stats">
<button
v-for="item in stats"
:key="item.key"
type="button"
class="stat-card"
:class="{ active: statusFilter === item.key }"
@click="statusFilter = item.key"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</button>
</div>
<div v-loading="loading" class="listing-card-panel">
<el-empty v-if="!displayListings.length" description="暂无符合条件的发布" />
<article v-for="item in displayListings" :key="item.id" class="listing-card">
<div class="listing-main">
<div class="listing-title-row">
<div class="listing-title-main">
<button class="listing-code-chip" type="button" @click="copyListingCode(item)">
编号 {{ formatListingCode(item) }}
<el-icon><CopyDocument /></el-icon>
</button>
<h3>{{ item.title }}</h3>
</div>
<div class="listing-tags">
<span class="status-pill" :class="statusTone(item.status)">{{
listingStatusLabel(item.status)
}}</span>
<span class="status-pill" :class="reviewTone(effectiveReviewStatus(item))">
{{ listingReviewStatusLabel(effectiveReviewStatus(item)) }}
</span>
</div>
</div>
<div class="listing-meta-grid">
<div>
<span>区服</span>
<strong>{{ item.server_region || '-' }}</strong>
</div>
<div>
<span>哈夫币</span>
<strong>{{ coinText(item) }}</strong>
</div>
<div>
<span>价格</span>
<strong class="price-text">{{ listingPrice(item) }}</strong>
</div>
<div>
<span>押金</span>
<strong>¥{{ item.deposit_amount }}</strong>
</div>
</div>
<p v-if="item.review_reason" class="review-reason">{{ item.review_reason }}</p>
</div>
<div class="listing-actions">
<el-button
:disabled="!canSubmit(item)"
:loading="submittingID === item.id"
@click="submitReview(item)"
>
提审
</el-button>
<el-button
type="danger"
plain
:disabled="!canOffline(item)"
:loading="offliningID === item.id"
@click="offline(item)"
>
下架
</el-button>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.seller-listings-page {
display: grid;
gap: 18px;
}
.seller-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 26px 28px;
border: 1px solid #e7edf7;
border-radius: 22px;
background: linear-gradient(135deg, #ffffff 0%, #f7fbff 58%, #eef6ff 100%);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06);
}
.seller-hero h1 {
margin: 0;
color: #0f172a;
font-size: 34px;
}
.seller-hero p:last-child {
margin: 10px 0 0;
color: #64748b;
font-weight: 700;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.seller-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.stat-card {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 76px;
padding: 16px 18px;
border: 1px solid #e6ebf2;
border-radius: 18px;
background: #fff;
color: #64748b;
cursor: pointer;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.stat-card span {
font-size: 13px;
font-weight: 800;
}
.stat-card strong {
color: #0f172a;
font-size: 24px;
}
.stat-card.active,
.stat-card:hover {
border-color: #409eff;
box-shadow: 0 12px 28px rgba(64, 158, 255, 0.14);
transform: translateY(-1px);
}
.listing-card-panel {
display: grid;
min-height: 220px;
gap: 12px;
}
.listing-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding: 18px;
border: 1px solid #e7edf5;
border-radius: 18px;
background: #fff;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
}
.listing-main {
min-width: 0;
}
.listing-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.listing-title-main {
display: grid;
min-width: 0;
gap: 6px;
}
.listing-code-chip {
display: inline-flex;
width: fit-content;
align-items: center;
gap: 5px;
padding: 4px 9px;
border: 1px solid #dbeafe;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.listing-title-row h3 {
min-width: 0;
margin: 0;
overflow: hidden;
color: #1f2937;
font-size: 16px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.listing-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.status-pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
border-radius: 999px;
background: #eef2f7;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.status-pill.success {
background: #ecfdf3;
color: #15803d;
}
.status-pill.warning {
background: #fff7ed;
color: #c2410c;
}
.status-pill.danger {
background: #fef2f2;
color: #dc2626;
}
.status-pill.info {
background: #eff6ff;
color: #2563eb;
}
.status-pill.muted {
background: #f1f5f9;
color: #64748b;
}
.listing-meta-grid {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 10px;
margin-top: 14px;
}
.listing-meta-grid div {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 12px;
background: #f8fafc;
}
.listing-meta-grid span {
color: #94a3b8;
font-size: 12px;
font-weight: 800;
}
.listing-meta-grid strong {
color: #334155;
font-size: 14px;
}
.price-text {
color: #f97316 !important;
}
.review-reason {
margin: 12px 0 0;
padding: 10px 12px;
border-radius: 12px;
background: #fff7ed;
color: #b45309;
font-size: 13px;
font-weight: 700;
line-height: 1.6;
}
.listing-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
@media (max-width: 900px) {
.seller-hero,
.listing-title-row {
flex-direction: column;
}
.listing-card {
grid-template-columns: 1fr;
}
.hero-actions,
.listing-actions,
.listing-tags {
justify-content: flex-start;
}
.seller-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.listing-meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.seller-hero {
padding: 20px;
}
.seller-stats,
.listing-meta-grid {
grid-template-columns: 1fr;
}
.listing-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
</style>