二次编辑商品
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -128,11 +128,21 @@ export async function fetchSellerListings() {
|
||||
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) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>('/listings', payload)
|
||||
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) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/listings/${id}/submit-review`)
|
||||
return data.data
|
||||
|
||||
@@ -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<Record<string, string[]>>({})
|
||||
const selectedSkins = ref<string[]>([])
|
||||
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<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 {
|
||||
...pricing,
|
||||
dailyLossOptions,
|
||||
@@ -785,6 +918,10 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
||||
selectedSkins,
|
||||
uploadedScreenshotCount,
|
||||
requiredScreenshotCount,
|
||||
isEditMode,
|
||||
pageTitle,
|
||||
submitButtonText,
|
||||
submitLoadingText,
|
||||
disabledOnlineStartOptions,
|
||||
disabledOnlineEndOptions,
|
||||
loadPublishOptions,
|
||||
|
||||
@@ -14,6 +14,10 @@ const {
|
||||
router,
|
||||
loading,
|
||||
uploading,
|
||||
isEditMode,
|
||||
pageTitle,
|
||||
submitButtonText,
|
||||
submitLoadingText,
|
||||
publishAgreements,
|
||||
virtualAssetSaleAgreementChecked,
|
||||
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()">
|
||||
<van-icon name="arrow-left" :size="20" />
|
||||
</button>
|
||||
<h1>发布账号</h1>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<span class="header-spacer"></span>
|
||||
</header>
|
||||
|
||||
@@ -710,7 +714,7 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
|
||||
|
||||
<div class="publish-actions">
|
||||
<van-button round class="reset-btn" :disabled="loading" @click="handleResetDraft">
|
||||
重置
|
||||
{{ isEditMode ? '恢复' : '重置' }}
|
||||
</van-button>
|
||||
<van-button
|
||||
type="primary"
|
||||
@@ -718,10 +722,10 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
|
||||
class="submit-btn"
|
||||
:loading="loading"
|
||||
:disabled="!canPublishAfterAgreements"
|
||||
loading-text="发布中..."
|
||||
:loading-text="submitLoadingText"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
保存发布
|
||||
{{ submitButtonText }}
|
||||
</van-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 }}
|
||||
</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 :icon="RefreshRight" :disabled="loading" @click="handleResetDraft"
|
||||
>重置草稿</el-button
|
||||
>{{ isEditMode ? '恢复原信息' : '重置草稿' }}</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<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 { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import {
|
||||
fetchSellerListings,
|
||||
@@ -18,6 +19,7 @@ const submittingID = ref<number | null>(null)
|
||||
const offliningID = ref<number | null>(null)
|
||||
const listings = ref<Listing[]>([])
|
||||
const statusFilter = ref('all')
|
||||
const route = useRoute()
|
||||
|
||||
const displayListings = computed(() => {
|
||||
if (statusFilter.value === 'all') return listings.value
|
||||
@@ -125,10 +127,20 @@ function canSubmit(row: Listing) {
|
||||
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) {
|
||||
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) {
|
||||
const tones: Record<string, string> = {
|
||||
published: 'success',
|
||||
@@ -235,6 +247,9 @@ function isPendingReview(row: Listing) {
|
||||
</div>
|
||||
|
||||
<div class="listing-actions">
|
||||
<RouterLink v-if="canEdit(item)" :to="editPath(item)">
|
||||
<el-button :icon="Edit">编辑</el-button>
|
||||
</RouterLink>
|
||||
<el-button
|
||||
:disabled="!canSubmit(item)"
|
||||
:loading="submittingID === item.id"
|
||||
|
||||
@@ -33,6 +33,9 @@ export function getMobilePath(path: string): string {
|
||||
if (path === '/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/')) {
|
||||
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 === '/realname') return '/realname'
|
||||
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.startsWith('/chats/')) return '/messages/' + subPath.substring(7)
|
||||
|
||||
@@ -85,6 +85,12 @@ export const mobileRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/seller/views/MobileSellerListingCreateView.vue'),
|
||||
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',
|
||||
name: 'mobile-seller-listings',
|
||||
|
||||
@@ -13,6 +13,12 @@ export const sellerRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/seller/views/SellerListingCreateView.vue'),
|
||||
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',
|
||||
name: 'seller-handoffs',
|
||||
|
||||
Reference in New Issue
Block a user