@@ -446,6 +521,13 @@ function formatConfigValue(row: SystemConfig) {
@saved="loadConfigs"
/>
+
+
>('/listing-publish-options')
return mergeListingPublishOptions(data.data)
}
+export async function fetchListingPublishAgreements() {
+ const { data } = await apiClient.get>('/listing-publish-agreements')
+ return mergeListingPublishAgreements(data.data)
+}
+
export async function fetchListingSalePriceConfig() {
const { data } = await apiClient.get>('/listing-sale-price-config')
return mergeListingSalePriceConfig(data.data)
@@ -193,6 +219,20 @@ export function mergeListingSalePriceConfig(options?: Partial): ListingPublishAgreements {
+ return {
+ virtual_asset_sale: normalizeAgreementContent(options?.virtual_asset_sale, emptyListingPublishAgreements.virtual_asset_sale),
+ seller_agreement: normalizeAgreementContent(options?.seller_agreement, emptyListingPublishAgreements.seller_agreement),
+ }
+}
+
+function normalizeAgreementContent(value: unknown, fallback: AgreementContent): AgreementContent {
+ const row = isRecord(value) ? value : {}
+ const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : fallback.title
+ const content = typeof row.content === 'string' && row.content.trim() ? row.content.trim() : fallback.content
+ return { title, content }
+}
+
function normalizeStringList(values?: unknown[]) {
return Array.isArray(values)
? values.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
diff --git a/frontend/src/features/listings/api/listings.ts b/frontend/src/features/listings/api/listings.ts
index 2ca1d4f..1f48e52 100644
--- a/frontend/src/features/listings/api/listings.ts
+++ b/frontend/src/features/listings/api/listings.ts
@@ -41,6 +41,8 @@ export interface ListingPayload {
screenshot_urls: string[]
price: number
deposit_amount: number
+ agreed_virtual_asset_sale: boolean
+ agreed_seller_agreement: boolean
}
export interface PublicListingQuery {
diff --git a/frontend/src/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts
index f8f1c19..6630926 100644
--- a/frontend/src/features/seller/composables/usePublishForm.ts
+++ b/frontend/src/features/seller/composables/usePublishForm.ts
@@ -3,11 +3,14 @@ import { useRouter } from 'vue-router'
import { fetchFileBlobByURL, uploadFile } from '@/shared/api/files'
import {
+ emptyListingPublishAgreements,
emptyListingPublishOptions,
emptyListingSalePriceConfig,
+ fetchListingPublishAgreements,
fetchListingPublishOptions,
fetchListingSalePriceConfig,
type ChargeMode,
+ type ListingPublishAgreements,
type ListingPublishOptions,
type PublishSalePriceConfig,
type ScreenshotKey,
@@ -45,6 +48,9 @@ export function usePublishForm(options: UsePublishFormOptions) {
const draftReady = ref(false)
const publishOptions = ref(emptyListingPublishOptions)
const salePriceConfig = ref(emptyListingSalePriceConfig)
+ const publishAgreements = ref(emptyListingPublishAgreements)
+ const virtualAssetSaleAgreementChecked = ref(false)
+ const sellerAgreementChecked = ref(false)
const fileInput = ref(null)
const activeUploadKey = ref('coin')
const form = reactive(defaultPublishForm())
@@ -66,6 +72,9 @@ export function usePublishForm(options: UsePublishFormOptions) {
})
const uploadedScreenshotCount = computed(() => pricing.screenshotUrls.value.length)
const requiredScreenshotCount = ref(0)
+ const canPublishAfterAgreements = computed(
+ () => virtualAssetSaleAgreementChecked.value && sellerAgreementChecked.value,
+ )
onMounted(() => {
restoreDraft()
@@ -114,12 +123,14 @@ export function usePublishForm(options: UsePublishFormOptions) {
async function loadPublishOptions() {
try {
- const [nextPublishOptions, nextSalePriceConfig] = await Promise.all([
+ const [nextPublishOptions, nextSalePriceConfig, nextPublishAgreements] = await Promise.all([
fetchListingPublishOptions(),
fetchListingSalePriceConfig(),
+ fetchListingPublishAgreements(),
])
publishOptions.value = nextPublishOptions
salePriceConfig.value = nextSalePriceConfig
+ publishAgreements.value = nextPublishAgreements
// 默认数字都是0
for (const item of nextPublishOptions.quantity_items) {
@@ -130,6 +141,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
} catch {
publishOptions.value = emptyListingPublishOptions
salePriceConfig.value = emptyListingSalePriceConfig
+ publishAgreements.value = emptyListingPublishAgreements
}
}
@@ -205,6 +217,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
clearRecord(screenshotFiles)
revokeAllScreenshotPreviews()
selectedSkins.value = []
+ virtualAssetSaleAgreementChecked.value = false
+ sellerAgreementChecked.value = false
activeUploadKey.value = 'coin'
// Also re-initialize quantityValues to 0
@@ -397,6 +411,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
screenshot_urls: pricing.screenshotUrls.value,
price: pricing.calculatedFinalPrice.value,
deposit_amount: Number(form.deposit_amount),
+ agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.value,
+ agreed_seller_agreement: sellerAgreementChecked.value,
})
removePublishDraft(options.draftKey)
suppressDraftSave.value = true
@@ -445,6 +461,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
}
if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布'
if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容'
+ if (!canPublishAfterAgreements.value) return '请先阅读并勾选两份发布协议'
for (const item of pricing.screenshotSlots.value) {
if (isScreenshotRequired(item) && !screenshotFiles[item.key]) return `请上传${item.label}`
}
@@ -530,6 +547,10 @@ export function usePublishForm(options: UsePublishFormOptions) {
router,
loading,
uploading,
+ publishAgreements,
+ virtualAssetSaleAgreementChecked,
+ sellerAgreementChecked,
+ canPublishAfterAgreements,
fileInput,
activeUploadKey,
form,
diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.css b/frontend/src/features/seller/views/MobileSellerListingCreateView.css
index c4bf80c..4d8fc82 100644
--- a/frontend/src/features/seller/views/MobileSellerListingCreateView.css
+++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.css
@@ -644,6 +644,64 @@
line-height: 1.5;
}
+.publish-agreement-card {
+ display: grid;
+ gap: 10px;
+ margin-bottom: 12px;
+ padding: 12px;
+ border: 1px solid #edf0f4;
+ border-radius: 8px;
+ background: #fff;
+}
+
+.agreement-check-label {
+ color: #30343a;
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1.45;
+}
+
+.agreement-link {
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: #1477ff;
+ font: inherit;
+}
+
+.publish-agreement-popup {
+ max-height: 86vh;
+}
+
+.agreement-popup-body {
+ display: grid;
+ gap: 12px;
+ max-height: 86vh;
+ overflow: auto;
+ padding: 20px 16px calc(16px + env(safe-area-inset-bottom));
+}
+
+.agreement-popup-body h3 {
+ margin: 0;
+ color: #17233d;
+ font-size: 18px;
+ font-weight: 900;
+}
+
+.agreement-popup-content {
+ max-height: 62vh;
+ overflow: auto;
+ padding: 12px;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background: #f8fafc;
+ color: #1f2937;
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.7;
+ white-space: pre-wrap;
+}
+
.publish-actions {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue
index 8e60e8a..40a1c2a 100644
--- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue
+++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue
@@ -1,8 +1,10 @@