二次编辑商品

This commit is contained in:
yml2213
2026-06-12 21:43:02 +08:00
parent c8b8922c8d
commit f1ae72cc7a
9 changed files with 251 additions and 20 deletions
@@ -3,6 +3,9 @@ package listing
import ( import (
"encoding/json" "encoding/json"
"testing" "testing"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
) )
func TestConsumableValueOnlyCountsChargedResources(t *testing.T) { func TestConsumableValueOnlyCountsChargedResources(t *testing.T) {
@@ -105,6 +108,46 @@ func TestCreateRequiresPublishAgreements(t *testing.T) {
} }
} }
func TestRepositoryUpdateAllowsOfflineListingResubmit(t *testing.T) {
db := database.NewTestDB()
if err := db.AutoMigrate(&model.GameAccount{}, &model.RentalListing{}); err != nil {
t.Fatalf("failed to migrate test db: %v", err)
}
repo := NewRepository(db)
req := CreateRequest{
Title: "测试账号",
ServerRegion: "QQ",
LoginPlatform: "QQ账号密码",
RankLevel: "黑鹰",
HafCoinAmount: 100000000,
PriceCent: 10000,
DepositAmountCent: 50000,
ScreenshotURLS: []string{"https://example.com/a.png"},
AssetSummary: map[string]any{
"online_time": map[string]any{"start": "09:00", "end": "23:00"},
},
}
created, err := repo.Create(t.Context(), 1, req, false)
if err != nil {
t.Fatalf("failed to create listing: %v", err)
}
if _, err := repo.Offline(t.Context(), 1, created.ID); err != nil {
t.Fatalf("failed to offline listing: %v", err)
}
req.Title = "二次编辑账号"
updated, err := repo.Update(t.Context(), 1, created.ID, req, true)
if err != nil {
t.Fatalf("expected offline listing can be updated, got %v", err)
}
if updated.Title != "二次编辑账号" {
t.Fatalf("expected title updated, got %q", updated.Title)
}
if updated.Status != "draft" || updated.ReviewStatus != "pending" {
t.Fatalf("expected draft/pending after resubmit, got %s/%s", updated.Status, updated.ReviewStatus)
}
}
func TestApplySellerListingPriceUsesSellerTotalPrice(t *testing.T) { func TestApplySellerListingPriceUsesSellerTotalPrice(t *testing.T) {
item := &ListingDTO{ item := &ListingDTO{
PriceCent: 23800, PriceCent: 23800,
@@ -128,11 +128,21 @@ export async function fetchSellerListings() {
return data.data.items return data.data.items
} }
export async function fetchSellerListing(id: string | number) {
const { data } = await apiClient.get<ApiResponse<Listing>>(`/seller/listings/${id}`)
return data.data
}
export async function createListing(payload: ListingPayload) { export async function createListing(payload: ListingPayload) {
const { data } = await apiClient.post<ApiResponse<Listing>>('/listings', payload) const { data } = await apiClient.post<ApiResponse<Listing>>('/listings', payload)
return data.data return data.data
} }
export async function updateListing(id: string | number, payload: ListingPayload) {
const { data } = await apiClient.put<ApiResponse<Listing>>(`/listings/${id}`, payload)
return data.data
}
export async function submitListingReview(id: number) { export async function submitListingReview(id: number) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/listings/${id}/submit-review`) const { data } = await apiClient.post<ApiResponse<Listing>>(`/listings/${id}/submit-review`)
return data.data return data.data
@@ -1,6 +1,6 @@
import { readError } from '@/shared/utils/error' import { readError } from '@/shared/utils/error'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { fetchFileBlobByURL, uploadFile } from '@/shared/api/files' import { fetchFileBlobByURL, uploadFile } from '@/shared/api/files'
import { import {
@@ -16,7 +16,12 @@ import {
type PublishSalePriceConfig, type PublishSalePriceConfig,
type ScreenshotKey, type ScreenshotKey,
} from '@/features/listings/api/listingOptions' } from '@/features/listings/api/listingOptions'
import { createListing } from '@/features/listings/api/listings' import {
createListing,
fetchSellerListing,
updateListing,
type Listing,
} from '@/features/listings/api/listings'
import { usePricingCalculator } from '@/shared/composables/usePricingCalculator' import { usePricingCalculator } from '@/shared/composables/usePricingCalculator'
import { import {
buildPublishDraft, buildPublishDraft,
@@ -51,6 +56,7 @@ interface UsePublishFormOptions {
export function usePublishForm(options: UsePublishFormOptions) { export function usePublishForm(options: UsePublishFormOptions) {
const router = useRouter() const router = useRouter()
const route = useRoute()
const loading = ref(false) const loading = ref(false)
const uploading = ref(false) const uploading = ref(false)
const suppressDraftSave = ref(false) const suppressDraftSave = ref(false)
@@ -69,6 +75,11 @@ export function usePublishForm(options: UsePublishFormOptions) {
const screenshotPreviews = reactive<Record<string, string[]>>({}) const screenshotPreviews = reactive<Record<string, string[]>>({})
const selectedSkins = ref<string[]>([]) const selectedSkins = ref<string[]>([])
let draftSaveTimer: number | undefined let draftSaveTimer: number | undefined
const editListingID = computed(() => Number(route.params.id || 0))
const isEditMode = computed(() => Number.isFinite(editListingID.value) && editListingID.value > 0)
const pageTitle = computed(() => (isEditMode.value ? '编辑账号' : '发布账号'))
const submitButtonText = computed(() => (isEditMode.value ? '提交修改' : '立即发布'))
const submitLoadingText = computed(() => (isEditMode.value ? '提交中...' : '发布中...'))
const pricing = usePricingCalculator({ const pricing = usePricingCalculator({
form, form,
@@ -93,11 +104,14 @@ export function usePublishForm(options: UsePublishFormOptions) {
commonOnlineTimes.filter(time => !canUseOnlineEnd(time)) commonOnlineTimes.filter(time => !canUseOnlineEnd(time))
) )
onMounted(() => { onMounted(async () => {
restoreDraft() if (!isEditMode.value) restoreDraft()
draftReady.value = true
if (options.persistBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload) if (options.persistBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload)
loadPublishOptions() await loadPublishOptions()
if (isEditMode.value) {
await loadEditingListing()
}
draftReady.value = true
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -164,7 +178,22 @@ export function usePublishForm(options: UsePublishFormOptions) {
} }
} }
async function loadEditingListing() {
if (!isEditMode.value) return
loading.value = true
try {
const listing = await fetchSellerListing(editListingID.value)
applyListingToForm(listing)
} catch (error) {
options.notifyError(readError(error, '商品信息加载失败'))
await router.push(options.submitSuccessPath)
} finally {
loading.value = false
}
}
function saveDraft(saveOptions: { force?: boolean } = {}) { function saveDraft(saveOptions: { force?: boolean } = {}) {
if (isEditMode.value) return
if (suppressDraftSave.value) { if (suppressDraftSave.value) {
clearDraftSaveTimer() clearDraftSaveTimer()
return return
@@ -229,6 +258,65 @@ export function usePublishForm(options: UsePublishFormOptions) {
hydrateScreenshotPreviews() hydrateScreenshotPreviews()
} }
function applyListingToForm(listing: Listing) {
const summary = recordValue(listing.asset_summary)
const onlineTime = recordValue(summary.online_time)
const screenshotGroups = recordValue(summary.screenshot_groups)
const skinGroups = recordValue(summary.skin_groups)
const breakdown = recordValue(summary.price_breakdown)
clearRecord(quantityValues)
clearRecord(quantityModes)
clearRecord(screenshotFiles)
revokeAllScreenshotPreviews()
Object.assign(form, {
server_region: listing.server_region || '',
face_owner: stringValue(summary.face_owner),
haf_coin_amount: listing.haf_coin_amount > 0 ? listing.haf_coin_amount / 1000000 : '',
rank_level: listing.rank_level || '',
secret_kd: stringValue(summary.secret_kd),
fire_level: numberOrEmpty(summary.fire_level),
daily_loss_m: numberOrDefault(summary.daily_loss_m, 10),
accelerated_sale_ratio: numberOrEmpty(breakdown.accelerated_sale_ratio),
season_insurance: stringValue(summary.season_insurance),
stamina_level: stringValue(summary.stamina_level),
load_level: stringValue(summary.load_level),
login_method: listing.login_platform || '',
online_start: stringValue(onlineTime.start),
online_end: stringValue(onlineTime.end),
ban_record: stringValue(summary.ban_record),
common_regions: stringArray(summary.common_regions),
depositAmountYuan: listing.deposit_amount_cent > 0 ? listing.deposit_amount_cent / 100 : '',
remark: listing.description || stringValue(summary.remark),
})
for (const item of publishOptions.value.quantity_items) {
quantityValues[item.key] = 0
}
for (const item of arrayValue(summary.resources)) {
const resource = recordValue(item)
const key = stringValue(resource.key)
if (!key) continue
quantityValues[key] = numberOrDefault(resource.quantity, 0)
const mode = stringValue(resource.mode)
quantityModes[key] = mode === '赠送' ? '赠送' : '收费'
}
for (const [key, urls] of Object.entries(screenshotGroups)) {
const cleaned = stringArray(urls)
if (cleaned.length) screenshotFiles[key] = cleaned
}
if (!Object.keys(screenshotFiles).length && listing.screenshot_urls.length) {
screenshotFiles.coin = [...listing.screenshot_urls]
}
selectedSkins.value = Object.values(skinGroups).flatMap(value => stringArray(value))
virtualAssetSaleAgreementChecked.value = true
sellerAgreementChecked.value = true
hydrateScreenshotPreviews()
}
function resetDraftState() { function resetDraftState() {
Object.assign(form, defaultPublishForm()) Object.assign(form, defaultPublishForm())
clearRecord(quantityValues) clearRecord(quantityValues)
@@ -351,6 +439,11 @@ export function usePublishForm(options: UsePublishFormOptions) {
} }
async function handleResetDraft() { async function handleResetDraft() {
if (isEditMode.value) {
await loadEditingListing()
options.notifySuccess('已恢复原商品信息')
return
}
try { try {
await options.confirmReset?.() await options.confirmReset?.()
} catch { } catch {
@@ -565,7 +658,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
} }
loading.value = true loading.value = true
try { try {
const listing = await createListing({ const payload = {
title: pricing.publishTitle.value, title: pricing.publishTitle.value,
description: form.remark, description: form.remark,
server_region: form.server_region, server_region: form.server_region,
@@ -578,18 +671,27 @@ export function usePublishForm(options: UsePublishFormOptions) {
deposit_amount_cent: yuanToCent(Number(form.depositAmountYuan)), deposit_amount_cent: yuanToCent(Number(form.depositAmountYuan)),
agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.value, agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.value,
agreed_seller_agreement: sellerAgreementChecked.value, agreed_seller_agreement: sellerAgreementChecked.value,
}) }
removePublishDraft(options.draftKey) const listing = isEditMode.value
? await updateListing(editListingID.value, payload)
: await createListing(payload)
if (!isEditMode.value) removePublishDraft(options.draftKey)
suppressDraftSave.value = true suppressDraftSave.value = true
clearDraftSaveTimer() clearDraftSaveTimer()
options.notifySuccess( options.notifySuccess(
listing.status === 'published' && listing.review_status === 'approved' listing.status === 'published' && listing.review_status === 'approved'
? '发布成功,已上架' ? isEditMode.value
: '发布成功,等待后台审核' ? '修改成功,已上架'
: '发布成功,已上架'
: isEditMode.value
? '修改成功,等待后台审核'
: '发布成功,等待后台审核'
) )
await router.push(options.submitSuccessPath) await router.push(options.submitSuccessPath)
} catch (error) { } catch (error) {
options.notifyError(readError(error, '发布失败,请确认已登录并完成实名认证')) options.notifyError(
readError(error, isEditMode.value ? '修改失败,请稍后重试' : '发布失败,请确认已登录并完成实名认证')
)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -763,6 +865,37 @@ export function usePublishForm(options: UsePublishFormOptions) {
return pricing.screenshotSlots.value.find(item => item.key === key)?.label || '该截图' return pricing.screenshotSlots.value.find(item => item.key === key)?.label || '该截图'
} }
function recordValue(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : []
}
function stringValue(value: unknown) {
if (value === undefined || value === null) return ''
return String(value)
}
function stringArray(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string' && Boolean(item))
: []
}
function numberOrEmpty(value: unknown): number | '' {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : ''
}
function numberOrDefault(value: unknown, fallback: number) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
return { return {
...pricing, ...pricing,
dailyLossOptions, dailyLossOptions,
@@ -785,6 +918,10 @@ export function usePublishForm(options: UsePublishFormOptions) {
selectedSkins, selectedSkins,
uploadedScreenshotCount, uploadedScreenshotCount,
requiredScreenshotCount, requiredScreenshotCount,
isEditMode,
pageTitle,
submitButtonText,
submitLoadingText,
disabledOnlineStartOptions, disabledOnlineStartOptions,
disabledOnlineEndOptions, disabledOnlineEndOptions,
loadPublishOptions, loadPublishOptions,
@@ -14,6 +14,10 @@ const {
router, router,
loading, loading,
uploading, uploading,
isEditMode,
pageTitle,
submitButtonText,
submitLoadingText,
publishAgreements, publishAgreements,
virtualAssetSaleAgreementChecked, virtualAssetSaleAgreementChecked,
sellerAgreementChecked, sellerAgreementChecked,
@@ -119,7 +123,7 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
<button class="back-btn" type="button" @click="router.back()"> <button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" /> <van-icon name="arrow-left" :size="20" />
</button> </button>
<h1>发布账号</h1> <h1>{{ pageTitle }}</h1>
<span class="header-spacer"></span> <span class="header-spacer"></span>
</header> </header>
@@ -710,7 +714,7 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
<div class="publish-actions"> <div class="publish-actions">
<van-button round class="reset-btn" :disabled="loading" @click="handleResetDraft"> <van-button round class="reset-btn" :disabled="loading" @click="handleResetDraft">
重置 {{ isEditMode ? '恢复' : '重置' }}
</van-button> </van-button>
<van-button <van-button
type="primary" type="primary"
@@ -718,10 +722,10 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
class="submit-btn" class="submit-btn"
:loading="loading" :loading="loading"
:disabled="!canPublishAfterAgreements" :disabled="!canPublishAfterAgreements"
loading-text="发布中..." :loading-text="submitLoadingText"
@click="handleSubmit" @click="handleSubmit"
> >
保存发布 {{ submitButtonText }}
</van-button> </van-button>
</div> </div>
</section> </section>
@@ -33,6 +33,8 @@ const {
selectedSkins, selectedSkins,
uploadedScreenshotCount, uploadedScreenshotCount,
requiredScreenshotCount, requiredScreenshotCount,
isEditMode,
submitButtonText,
disabledOnlineStartOptions, disabledOnlineStartOptions,
disabledOnlineEndOptions, disabledOnlineEndOptions,
serverOptions, serverOptions,
@@ -684,13 +686,17 @@ function selectDailyLoss(value: string | number) {
:disabled="!canPublishAfterAgreements" :disabled="!canPublishAfterAgreements"
@click="handleSubmit" @click="handleSubmit"
> >
立即发布 {{ submitButtonText }}
</el-button> </el-button>
<el-button :icon="DocumentChecked" :disabled="loading" @click="handleSaveDraft" <el-button
v-if="!isEditMode"
:icon="DocumentChecked"
:disabled="loading"
@click="handleSaveDraft"
>保存草稿</el-button >保存草稿</el-button
> >
<el-button :icon="RefreshRight" :disabled="loading" @click="handleResetDraft" <el-button :icon="RefreshRight" :disabled="loading" @click="handleResetDraft"
>重置草稿</el-button >{{ isEditMode ? '恢复原信息' : '重置草稿' }}</el-button
> >
</div> </div>
</div> </div>
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { CopyDocument, Plus, Refresh } from '@element-plus/icons-vue' import { CopyDocument, Edit, Plus, Refresh } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { import {
fetchSellerListings, fetchSellerListings,
@@ -18,6 +19,7 @@ const submittingID = ref<number | null>(null)
const offliningID = ref<number | null>(null) const offliningID = ref<number | null>(null)
const listings = ref<Listing[]>([]) const listings = ref<Listing[]>([])
const statusFilter = ref('all') const statusFilter = ref('all')
const route = useRoute()
const displayListings = computed(() => { const displayListings = computed(() => {
if (statusFilter.value === 'all') return listings.value if (statusFilter.value === 'all') return listings.value
@@ -125,10 +127,20 @@ function canSubmit(row: Listing) {
return !isPendingReview(row) && row.status !== 'rented' && row.status !== 'published' return !isPendingReview(row) && row.status !== 'rented' && row.status !== 'published'
} }
function canEdit(row: Listing) {
return !isPendingReview(row) && row.status !== 'rented' && row.status !== 'published'
}
function canOffline(row: Listing) { function canOffline(row: Listing) {
return row.status !== 'rented' && row.status !== 'offline' return row.status !== 'rented' && row.status !== 'offline'
} }
function editPath(row: Listing) {
return route.path.startsWith('/m/')
? `/m/seller/listings/${row.id}/edit`
: `/seller/listings/${row.id}/edit`
}
function statusTone(status: string) { function statusTone(status: string) {
const tones: Record<string, string> = { const tones: Record<string, string> = {
published: 'success', published: 'success',
@@ -235,6 +247,9 @@ function isPendingReview(row: Listing) {
</div> </div>
<div class="listing-actions"> <div class="listing-actions">
<RouterLink v-if="canEdit(item)" :to="editPath(item)">
<el-button :icon="Edit">编辑</el-button>
</RouterLink>
<el-button <el-button
:disabled="!canSubmit(item)" :disabled="!canSubmit(item)"
:loading="submittingID === item.id" :loading="submittingID === item.id"
+4
View File
@@ -33,6 +33,9 @@ export function getMobilePath(path: string): string {
if (path === '/seller/listings/create') { if (path === '/seller/listings/create') {
return '/m/seller/listings/create' return '/m/seller/listings/create'
} }
if (path.startsWith('/seller/listings/') && path.endsWith('/edit')) {
return `/m${path}`
}
if (path === '/messages' || path.startsWith('/messages/')) { if (path === '/messages' || path.startsWith('/messages/')) {
return '/m/messages' return '/m/messages'
} }
@@ -66,6 +69,7 @@ export function getPcPath(path: string): string {
if (subPath === '/register') return '/login' // PC uses /login for both auth actions if (subPath === '/register') return '/login' // PC uses /login for both auth actions
if (subPath === '/realname') return '/realname' if (subPath === '/realname') return '/realname'
if (subPath === '/seller/listings/create') return '/seller/listings/create' if (subPath === '/seller/listings/create') return '/seller/listings/create'
if (subPath.startsWith('/seller/listings/') && subPath.endsWith('/edit')) return subPath
if (subPath === '/messages') return '/messages' if (subPath === '/messages') return '/messages'
if (subPath.startsWith('/chats/')) return '/messages/' + subPath.substring(7) if (subPath.startsWith('/chats/')) return '/messages/' + subPath.substring(7)
+6
View File
@@ -85,6 +85,12 @@ export const mobileRoutes: RouteRecordRaw[] = [
component: () => import('@/features/seller/views/MobileSellerListingCreateView.vue'), component: () => import('@/features/seller/views/MobileSellerListingCreateView.vue'),
meta: { layout: 'blank', requiresAuth: true, requiresRealname: true }, meta: { layout: 'blank', requiresAuth: true, requiresRealname: true },
}, },
{
path: '/m/seller/listings/:id/edit',
name: 'mobile-seller-listing-edit',
component: () => import('@/features/seller/views/MobileSellerListingCreateView.vue'),
meta: { layout: 'blank', requiresAuth: true, requiresRealname: true },
},
{ {
path: '/m/seller/listings', path: '/m/seller/listings',
name: 'mobile-seller-listings', name: 'mobile-seller-listings',
+6
View File
@@ -13,6 +13,12 @@ export const sellerRoutes: RouteRecordRaw[] = [
component: () => import('@/features/seller/views/SellerListingCreateView.vue'), component: () => import('@/features/seller/views/SellerListingCreateView.vue'),
meta: { requiresAuth: true, requiresRealname: true }, meta: { requiresAuth: true, requiresRealname: true },
}, },
{
path: '/seller/listings/:id/edit',
name: 'seller-listing-edit',
component: () => import('@/features/seller/views/SellerListingCreateView.vue'),
meta: { requiresAuth: true, requiresRealname: true },
},
{ {
path: '/seller/handoffs', path: '/seller/handoffs',
name: 'seller-handoffs', name: 'seller-handoffs',