diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 9414b5c..93e94c5 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -3,6 +3,9 @@ package listing import ( "encoding/json" "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" ) 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) { item := &ListingDTO{ PriceCent: 23800, diff --git a/frontend/src/features/listings/api/listings.ts b/frontend/src/features/listings/api/listings.ts index c10c444..9719e19 100644 --- a/frontend/src/features/listings/api/listings.ts +++ b/frontend/src/features/listings/api/listings.ts @@ -128,11 +128,21 @@ export async function fetchSellerListings() { return data.data.items } +export async function fetchSellerListing(id: string | number) { + const { data } = await apiClient.get>(`/seller/listings/${id}`) + return data.data +} + export async function createListing(payload: ListingPayload) { const { data } = await apiClient.post>('/listings', payload) return data.data } +export async function updateListing(id: string | number, payload: ListingPayload) { + const { data } = await apiClient.put>(`/listings/${id}`, payload) + return data.data +} + export async function submitListingReview(id: number) { const { data } = await apiClient.post>(`/listings/${id}/submit-review`) return data.data diff --git a/frontend/src/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts index 85628bc..617bbde 100644 --- a/frontend/src/features/seller/composables/usePublishForm.ts +++ b/frontend/src/features/seller/composables/usePublishForm.ts @@ -1,6 +1,6 @@ import { readError } from '@/shared/utils/error' 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 { @@ -16,7 +16,12 @@ import { type PublishSalePriceConfig, type ScreenshotKey, } 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 { buildPublishDraft, @@ -51,6 +56,7 @@ interface UsePublishFormOptions { export function usePublishForm(options: UsePublishFormOptions) { const router = useRouter() + const route = useRoute() const loading = ref(false) const uploading = ref(false) const suppressDraftSave = ref(false) @@ -69,6 +75,11 @@ export function usePublishForm(options: UsePublishFormOptions) { const screenshotPreviews = reactive>({}) const selectedSkins = ref([]) 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({ form, @@ -93,11 +104,14 @@ export function usePublishForm(options: UsePublishFormOptions) { commonOnlineTimes.filter(time => !canUseOnlineEnd(time)) ) - onMounted(() => { - restoreDraft() - draftReady.value = true + onMounted(async () => { + if (!isEditMode.value) restoreDraft() if (options.persistBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload) - loadPublishOptions() + await loadPublishOptions() + if (isEditMode.value) { + await loadEditingListing() + } + draftReady.value = true }) 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 } = {}) { + if (isEditMode.value) return if (suppressDraftSave.value) { clearDraftSaveTimer() return @@ -229,6 +258,65 @@ export function usePublishForm(options: UsePublishFormOptions) { 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() { Object.assign(form, defaultPublishForm()) clearRecord(quantityValues) @@ -351,6 +439,11 @@ export function usePublishForm(options: UsePublishFormOptions) { } async function handleResetDraft() { + if (isEditMode.value) { + await loadEditingListing() + options.notifySuccess('已恢复原商品信息') + return + } try { await options.confirmReset?.() } catch { @@ -565,7 +658,7 @@ export function usePublishForm(options: UsePublishFormOptions) { } loading.value = true try { - const listing = await createListing({ + const payload = { title: pricing.publishTitle.value, description: form.remark, server_region: form.server_region, @@ -578,18 +671,27 @@ export function usePublishForm(options: UsePublishFormOptions) { deposit_amount_cent: yuanToCent(Number(form.depositAmountYuan)), agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.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 clearDraftSaveTimer() options.notifySuccess( listing.status === 'published' && listing.review_status === 'approved' - ? '发布成功,已上架' - : '发布成功,等待后台审核' + ? isEditMode.value + ? '修改成功,已上架' + : '发布成功,已上架' + : isEditMode.value + ? '修改成功,等待后台审核' + : '发布成功,等待后台审核' ) await router.push(options.submitSuccessPath) } catch (error) { - options.notifyError(readError(error, '发布失败,请确认已登录并完成实名认证')) + options.notifyError( + readError(error, isEditMode.value ? '修改失败,请稍后重试' : '发布失败,请确认已登录并完成实名认证') + ) } finally { loading.value = false } @@ -763,6 +865,37 @@ export function usePublishForm(options: UsePublishFormOptions) { return pricing.screenshotSlots.value.find(item => item.key === key)?.label || '该截图' } + function recordValue(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {} + } + + 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 { ...pricing, dailyLossOptions, @@ -785,6 +918,10 @@ export function usePublishForm(options: UsePublishFormOptions) { selectedSkins, uploadedScreenshotCount, requiredScreenshotCount, + isEditMode, + pageTitle, + submitButtonText, + submitLoadingText, disabledOnlineStartOptions, disabledOnlineEndOptions, loadPublishOptions, diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue index d1800fc..961d6a0 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue @@ -14,6 +14,10 @@ const { router, loading, uploading, + isEditMode, + pageTitle, + submitButtonText, + submitLoadingText, publishAgreements, virtualAssetSaleAgreementChecked, sellerAgreementChecked, @@ -119,7 +123,7 @@ function selectRadio(value: T, setter: (value: T) => void) { -

发布账号

+

{{ pageTitle }}

@@ -710,7 +714,7 @@ function selectRadio(value: T, setter: (value: T) => void) {
- 重置 + {{ isEditMode ? '恢复' : '重置' }} (value: T, setter: (value: T) => void) { class="submit-btn" :loading="loading" :disabled="!canPublishAfterAgreements" - loading-text="发布中..." + :loading-text="submitLoadingText" @click="handleSubmit" > - 保存发布 + {{ submitButtonText }}
diff --git a/frontend/src/features/seller/views/SellerListingCreateView.vue b/frontend/src/features/seller/views/SellerListingCreateView.vue index cd4e475..4379a13 100644 --- a/frontend/src/features/seller/views/SellerListingCreateView.vue +++ b/frontend/src/features/seller/views/SellerListingCreateView.vue @@ -33,6 +33,8 @@ const { selectedSkins, uploadedScreenshotCount, requiredScreenshotCount, + isEditMode, + submitButtonText, disabledOnlineStartOptions, disabledOnlineEndOptions, serverOptions, @@ -684,13 +686,17 @@ function selectDailyLoss(value: string | number) { :disabled="!canPublishAfterAgreements" @click="handleSubmit" > - 立即发布 + {{ submitButtonText }} - 保存草稿 重置草稿{{ isEditMode ? '恢复原信息' : '重置草稿' }} diff --git a/frontend/src/features/seller/views/SellerListingsView.vue b/frontend/src/features/seller/views/SellerListingsView.vue index c23687f..dc67155 100644 --- a/frontend/src/features/seller/views/SellerListingsView.vue +++ b/frontend/src/features/seller/views/SellerListingsView.vue @@ -1,7 +1,8 @@