修复用户发布下架后审核还有的bug

This commit is contained in:
yml2213
2026-06-07 22:07:54 +08:00
parent a8b3d3813b
commit ebf9b95f5a
5 changed files with 457 additions and 57 deletions
+3 -2
View File
@@ -254,11 +254,12 @@ func (h *Handler) Offline(c *gin.Context) {
if !ok {
return
}
if err := h.service.Offline(ownerID, id); err != nil {
item, err := h.service.Offline(ownerID, id)
if err != nil {
writeListingError(c, err)
return
}
response.OK(c, gin.H{"offline": true})
response.OK(c, item)
}
func (h *Handler) ListPublic(c *gin.Context) {
+26 -8
View File
@@ -252,7 +252,7 @@ func (r *Repository) SubmitReview(ownerID uint64, listingID uint64, reviewRequir
func (r *Repository) ListPendingReview() ([]ListingDTO, error) {
var rows []listingRow
err := r.baseQuery().
Where("l.review_status = ?", "pending").
Where("l.review_status = ? AND l.status <> ?", "pending", "offline").
Order("l.updated_at ASC, l.id ASC").
Limit(200).
Scan(&rows).Error
@@ -612,8 +612,9 @@ func (r *Repository) Reject(listingID uint64, req ReviewRequest) (*ListingDTO, e
return dto, err
}
func (r *Repository) Offline(ownerID uint64, listingID uint64) error {
return r.db.Transaction(func(tx *gorm.DB) error {
func (r *Repository) Offline(ownerID uint64, listingID uint64) (*ListingDTO, error) {
var dto *ListingDTO
err := r.db.Transaction(func(tx *gorm.DB) error {
listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID)
if err != nil {
return err
@@ -622,12 +623,20 @@ func (r *Repository) Offline(ownerID uint64, listingID uint64) error {
return ErrListingLocked
}
listing.Status = "offline"
listing.ReviewStatus = "none"
listing.ReviewReason = "号主已手动下架"
listing.PublishedAt = nil
account.Status = "offline"
if err := tx.Save(account).Error; err != nil {
return err
}
return tx.Save(listing).Error
if err := tx.Save(listing).Error; err != nil {
return err
}
dto = toDTO(*account, *listing)
return nil
})
return dto, err
}
func (r *Repository) ListPublic(query PublicListQuery) (*PublicListResult, error) {
@@ -1382,6 +1391,7 @@ func applySellerListingPrice(item *ListingDTO) {
func (row listingRow) toDTO() ListingDTO {
assetSummary := decodeAssetSummary(row.AssetSummary)
screenshotURLS := cleanScreenshotURLs(decodeScreenshots(row.ScreenshotURLS))
reviewStatus, reviewReason := normalizedReviewState(row.Status, row.ReviewStatus, row.ReviewReason)
return ListingDTO{
ID: row.ID,
AccountID: row.AccountID,
@@ -1403,8 +1413,8 @@ func (row listingRow) toDTO() ListingDTO {
IsAccelerated: isAcceleratedSale(assetSummary),
InTransaction: row.InTransaction,
Status: row.Status,
ReviewStatus: row.ReviewStatus,
ReviewReason: row.ReviewReason,
ReviewStatus: reviewStatus,
ReviewReason: reviewReason,
PublishedAt: row.PublishedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
@@ -1414,6 +1424,7 @@ func (row listingRow) toDTO() ListingDTO {
func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
assetSummary := decodeAssetSummary(account.AssetSummary)
screenshotURLS := cleanScreenshotURLs(decodeScreenshots(account.ScreenshotURLS))
reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason)
return &ListingDTO{
ID: listing.ID,
AccountID: account.ID,
@@ -1433,14 +1444,21 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
IsAccelerated: isAcceleratedSale(assetSummary),
InTransaction: listing.InTransaction,
Status: listing.Status,
ReviewStatus: listing.ReviewStatus,
ReviewReason: listing.ReviewReason,
ReviewStatus: reviewStatus,
ReviewReason: reviewReason,
PublishedAt: listing.PublishedAt,
CreatedAt: listing.CreatedAt,
UpdatedAt: listing.UpdatedAt,
}
}
func normalizedReviewState(status string, reviewStatus string, reviewReason string) (string, string) {
if status == "offline" {
return "none", reviewReason
}
return reviewStatus, reviewReason
}
func marshalScreenshots(urls []string) (datatypes.JSON, error) {
cleaned := cleanScreenshotURLs(urls)
if len(cleaned) > 12 {
+2 -2
View File
@@ -266,9 +266,9 @@ func (s *Service) Reject(id uint64, req ReviewRequest) (*ListingDTO, error) {
return s.repo.Reject(id, req)
}
func (s *Service) Offline(ownerID uint64, id uint64) error {
func (s *Service) Offline(ownerID uint64, id uint64) (*ListingDTO, error) {
if s.repo == nil {
return ErrDependencyUnavailable
return nil, ErrDependencyUnavailable
}
return s.repo.Offline(ownerID, id)
}
@@ -129,7 +129,7 @@ export async function submitListingReview(id: number) {
}
export async function offlineListing(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(`/listings/${id}`)
const { data } = await apiClient.delete<ApiResponse<Listing>>(`/listings/${id}`)
return data.data
}
@@ -1,13 +1,30 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { 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 { 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)
@@ -20,67 +37,431 @@ async function loadListings() {
}
}
async function submitReview(id: number) {
const listing = await submitListingReview(id)
ElMessage.success(
listing.status === 'published' && listing.review_status === 'approved'
? '已上架'
: '已提交审核,等待后台处理',
)
await loadListings()
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(id: number) {
await offlineListing(id)
ElMessage.success('下架')
await loadListings()
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))}`
}
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">
<div class="page-header page-header-row">
<section class="page seller-listings-page">
<div class="seller-hero">
<div>
<p class="eyebrow">Seller</p>
<h1>我的发布</h1>
<p>管理账号发布审核状态上架下架</p>
<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>
<RouterLink to="/seller/listings/create">
<el-button type="primary">发布账号</el-button>
</RouterLink>
</div>
<el-table v-loading="loading" class="table-panel" :data="listings">
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="server_region" label="区服" width="120" />
<el-table-column prop="haf_coin_amount" label="哈夫币" width="120" />
<el-table-column label="价格" width="100">
<template #default="{ row }">{{ listingPrice(row) }}</template>
</el-table-column>
<el-table-column prop="deposit_amount" label="押金" width="100" />
<el-table-column label="状态" width="110">
<template #default="{ row }">{{ listingStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="审核" width="110">
<template #default="{ row }">{{ listingReviewStatusLabel(row.review_status) }}</template>
</el-table-column>
<el-table-column prop="review_reason" label="审核原因" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="190">
<template #default="{ row }">
<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">
<h3>{{ item.title }}</h3>
<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
size="small"
:disabled="row.review_status === 'pending' || row.status === 'rented' || row.status === 'published'"
@click="submitReview(row.id)"
:disabled="!canSubmit(item)"
:loading="submittingID === item.id"
@click="submitReview(item)"
>
提审
</el-button>
<el-button size="small" type="danger" :disabled="row.status === 'rented'" @click="offline(row.id)">下架</el-button>
</template>
</el-table-column>
</el-table>
<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-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>