530 lines
12 KiB
Vue
530 lines
12 KiB
Vue
<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>
|