diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts new file mode 100644 index 0000000..15cdd65 --- /dev/null +++ b/frontend/src/features/auth/api/auth.ts @@ -0,0 +1,56 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' +import type { RealnameStatusValue, RiskStatus, UserStatus } from '@/types/status' + +export interface AuthUser { + id: number + phone: string + nickname: string + avatar_url: string + realname_status: RealnameStatusValue + risk_status: RiskStatus + credit_score: number + status: UserStatus +} + +export interface TokenPair { + access_token: string + refresh_token: string + token_type: string + expires_in: number +} + +export interface LoginData { + user: AuthUser + tokens: TokenPair +} + +export async function sendSmsCode(phone: string) { + const { data } = await apiClient.post>('/auth/sms/send', { + phone, + }) + return data.data +} + +export async function loginWithSms(phone: string, code: string) { + const { data } = await apiClient.post>('/auth/sms/login', { phone, code }) + return data.data +} + +export async function fetchMe() { + const { data } = await apiClient.get>('/me') + return data.data +} + +export async function updateMe(payload: Pick) { + const { data } = await apiClient.put>('/me', payload) + return data.data +} + +/** Manually refresh user token (for store to call on app init) */ +export async function refreshUserToken(refreshToken: string) { + const { data } = await apiClient.post>('/auth/refresh', { + refresh_token: refreshToken, + }) + return data.data +} diff --git a/frontend/src/features/auth/api/notifications.ts b/frontend/src/features/auth/api/notifications.ts new file mode 100644 index 0000000..91e3c57 --- /dev/null +++ b/frontend/src/features/auth/api/notifications.ts @@ -0,0 +1,27 @@ +import { apiClient } from './client' + +import type { ApiResponse, PaginatedResult } from './types' + +export interface NotificationItem { + id: number + user_id: number + type: string + title: string + content: string + biz_type: string + biz_id?: number + read_at?: string + created_at: string +} + +export async function fetchNotifications(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/notifications', { + params: { page, page_size: pageSize }, + }) + return data.data +} + +export async function markNotificationRead(id: number) { + const { data } = await apiClient.post>(`/notifications/${id}/read`) + return data.data +} diff --git a/frontend/src/features/auth/api/realname.ts b/frontend/src/features/auth/api/realname.ts new file mode 100644 index 0000000..8acc319 --- /dev/null +++ b/frontend/src/features/auth/api/realname.ts @@ -0,0 +1,26 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from './types' +import type { RealnameStatusValue } from '@/types/status' + +export interface RealnameStatus { + status: RealnameStatusValue + provider?: string + provider_order_no?: string + masked_name?: string + masked_id_no?: string + verified_at?: string + fail_reason?: string +} + +export async function startRealname(name: string, idNo: string) { + const { data } = await apiClient.post>('/realname/start', { + name, + id_no: idNo, + }) + return data.data +} + +export async function getRealnameStatus() { + const { data } = await apiClient.get>('/realname/status') + return data.data +} diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts new file mode 100644 index 0000000..f4343d0 --- /dev/null +++ b/frontend/src/features/auth/index.ts @@ -0,0 +1,4 @@ +// Auth 模块统一导出 +export * from './api/auth' +export * from './api/realname' +export * from './api/notifications' diff --git a/frontend/src/features/auth/views/LoginView.vue b/frontend/src/features/auth/views/LoginView.vue new file mode 100644 index 0000000..8324578 --- /dev/null +++ b/frontend/src/features/auth/views/LoginView.vue @@ -0,0 +1,409 @@ + + + + + diff --git a/frontend/src/features/auth/views/MobileLoginView.vue b/frontend/src/features/auth/views/MobileLoginView.vue new file mode 100644 index 0000000..9df2b56 --- /dev/null +++ b/frontend/src/features/auth/views/MobileLoginView.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue new file mode 100644 index 0000000..95e81f0 --- /dev/null +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -0,0 +1,1081 @@ + + + + + diff --git a/frontend/src/features/auth/views/MobileRealnameView.vue b/frontend/src/features/auth/views/MobileRealnameView.vue new file mode 100644 index 0000000..ecc634b --- /dev/null +++ b/frontend/src/features/auth/views/MobileRealnameView.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/frontend/src/features/auth/views/MobileRegisterView.vue b/frontend/src/features/auth/views/MobileRegisterView.vue new file mode 100644 index 0000000..bb3ee52 --- /dev/null +++ b/frontend/src/features/auth/views/MobileRegisterView.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/frontend/src/features/auth/views/NotificationsView.vue b/frontend/src/features/auth/views/NotificationsView.vue new file mode 100644 index 0000000..7bc4f38 --- /dev/null +++ b/frontend/src/features/auth/views/NotificationsView.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/features/auth/views/ProfileView.vue b/frontend/src/features/auth/views/ProfileView.vue new file mode 100644 index 0000000..038e403 --- /dev/null +++ b/frontend/src/features/auth/views/ProfileView.vue @@ -0,0 +1,714 @@ + + + + + diff --git a/frontend/src/features/auth/views/RealnameView.vue b/frontend/src/features/auth/views/RealnameView.vue new file mode 100644 index 0000000..b199d1a --- /dev/null +++ b/frontend/src/features/auth/views/RealnameView.vue @@ -0,0 +1,584 @@ + + + + + diff --git a/frontend/src/features/listings/api/homeConfig.ts b/frontend/src/features/listings/api/homeConfig.ts new file mode 100644 index 0000000..05bd33d --- /dev/null +++ b/frontend/src/features/listings/api/homeConfig.ts @@ -0,0 +1,95 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' +import { + mergeListingPublishOptions, + type ListingPublishOptions, +} from './listingOptions' + +export interface HomeBannerSlide { + eyebrow: string + title: string + badge: string + pill: string + tone: string + image_url?: string +} + +export interface MobileHomeConfig { + announcements: string[] + banners: HomeBannerSlide[] + publish_options: ListingPublishOptions +} + +export const defaultHomeAnnouncements = [ + '平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。', + '优先推荐同地区账号,减少异地登录保护触发。', + '下单前请核对哈夫币、保险、体力负重和截图信息。', +] + +export const defaultHomeBanners: HomeBannerSlide[] = [ + { + eyebrow: '三角洲行动账号专区', + title: '高哈夫币 · 安全交接 · 随租随玩', + badge: 'HOT', + pill: '新人领券最高减 20 元', + tone: 'blue', + image_url: '', + }, + { + eyebrow: '平台担保交易', + title: '交接全程留痕,拒绝私下转账', + badge: 'SAFE', + pill: '租号前先验实名与资料', + tone: 'green', + image_url: '', + }, + { + eyebrow: '高效筛选', + title: '按区服、段位、哈夫币快速找号', + badge: 'FAST', + pill: '支持扫码号与账密号', + tone: 'orange', + image_url: '', + }, +] + +export async function fetchMobileHomeConfig() { + const { data } = await apiClient.get>('/mobile-home-config') + return mergeHomeConfig(data.data) +} + +export function mergeHomeConfig(config?: Partial): MobileHomeConfig { + const announcements = + config?.announcements + ?.map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean) || [] + const banners = + config?.banners + ?.map((item) => { + const banner = isBannerLike(item) ? item : ({} as Partial) + return { + eyebrow: banner.eyebrow?.trim() || '', + title: banner.title?.trim() || '', + badge: banner.badge?.trim() || '', + pill: banner.pill?.trim() || '', + tone: normalizeBannerTone(banner.tone), + image_url: banner.image_url?.trim() || '', + } + }) + .filter((item) => item.title || item.image_url) || [] + + return { + announcements: announcements.length ? announcements : defaultHomeAnnouncements, + banners: banners.length ? banners : defaultHomeBanners, + publish_options: mergeListingPublishOptions(config?.publish_options), + } +} + +function isBannerLike(value: unknown): value is Partial { + return typeof value === 'object' && value !== null +} + +function normalizeBannerTone(tone?: string) { + if (tone === 'green' || tone === 'orange') return tone + return 'blue' +} diff --git a/frontend/src/features/listings/api/listingOptions.ts b/frontend/src/features/listings/api/listingOptions.ts new file mode 100644 index 0000000..2818324 --- /dev/null +++ b/frontend/src/features/listings/api/listingOptions.ts @@ -0,0 +1,398 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' + +export type ChargeMode = '赠送' | '收费' + +export type QuantityKey = string + +export type ScreenshotKey = string + +export type SkinCategoryKey = string + +export interface PublishOptionGroup { + key: SkinCategoryKey | string + title: string + options: string[] +} + +export interface PublishQuantityItem { + key: QuantityKey + label: string + price: string + placeholder?: string +} + +export interface PublishScreenshotSlot { + key: ScreenshotKey + label: string + required: boolean + hint: string +} + +export interface PublishPriceConfig { + deposit_placeholder: string + price_placeholder: string + ratio_description: string +} + +export interface PublishDepositSkinGroupRule { + group_key: string + label: string + amount_per_item: number +} + +export interface PublishDepositRecommendConfig { + base_amount: number + skin_group_rules: PublishDepositSkinGroupRule[] +} + +export interface PublishInsuranceBaseRatio { + insurance: string + ratio: number +} + +export interface PublishRatioConfigItem { + key: string + label: string + kind: string + group_key?: string + missing_penalty: number +} + +export interface PublishCoinCorrection { + threshold_m: number + correction: number +} + +export interface PublishSaleFixedMarkupRule { + min_m: number + max_m: number + markup_amount: number +} + +export interface PublishSaleRatioAdjustmentRule { + min_m: number + max_m: number + ratio_subtract: number +} + +export interface PublishRatioConfig { + insurance_base_ratios: PublishInsuranceBaseRatio[] + config_items: PublishRatioConfigItem[] + coin_corrections: PublishCoinCorrection[] +} + +export interface PublishSalePriceConfig { + fixed_markup_rules: PublishSaleFixedMarkupRule[] + ratio_adjustment_rules: PublishSaleRatioAdjustmentRule[] +} + +export interface ListingPublishOptions { + server_options: string[] + face_options: string[] + rank_options: string[] + insurance_options: string[] + level_options: string[] + login_method_options: string[] + region_options: string[] + skin_groups: PublishOptionGroup[] + quantity_items: PublishQuantityItem[] + screenshot_slots: PublishScreenshotSlot[] + ban_record_options: string[] + ban_evidence_options: string[] + fire_level_min: number + price_config: PublishPriceConfig + deposit_recommend_config: PublishDepositRecommendConfig + ratio_config: PublishRatioConfig +} + +export const emptyListingPublishOptions: ListingPublishOptions = { + server_options: [], + face_options: [], + rank_options: [], + insurance_options: [], + level_options: [], + login_method_options: [], + region_options: [], + skin_groups: [], + quantity_items: [], + screenshot_slots: [], + ban_record_options: [], + ban_evidence_options: [], + fire_level_min: 38, + price_config: { + deposit_placeholder: '', + price_placeholder: '', + ratio_description: '', + }, + deposit_recommend_config: { + base_amount: 50, + skin_group_rules: [ + { group_key: 'melee', label: '刀皮', amount_per_item: 5 }, + { group_key: 'operatorGold', label: '干员金皮', amount_per_item: 10 }, + { group_key: 'operatorRed', label: '干员红皮', amount_per_item: 30 }, + ], + }, + ratio_config: { + insurance_base_ratios: [], + config_items: [], + coin_corrections: [], + }, +} + +export const emptyListingSalePriceConfig: PublishSalePriceConfig = { + fixed_markup_rules: [ + { min_m: 10, max_m: 30, markup_amount: 28 }, + { min_m: 30, max_m: 50, markup_amount: 31 }, + { min_m: 50, max_m: 70, markup_amount: 34 }, + { min_m: 70, max_m: 90, markup_amount: 38 }, + ], + ratio_adjustment_rules: [ + { min_m: 90, max_m: 150, ratio_subtract: 5 }, + { min_m: 150, max_m: 230, ratio_subtract: 4 }, + { min_m: 230, max_m: 310, ratio_subtract: 3.5 }, + { min_m: 310, max_m: 390, ratio_subtract: 3 }, + { min_m: 390, max_m: 470, ratio_subtract: 0 }, + { min_m: 470, max_m: 550, ratio_subtract: 0 }, + { min_m: 550, max_m: 0, ratio_subtract: 0 }, + ], +} + +export async function fetchListingPublishOptions() { + const { data } = await apiClient.get>('/listing-publish-options') + return mergeListingPublishOptions(data.data) +} + +export async function fetchListingSalePriceConfig() { + const { data } = await apiClient.get>('/listing-sale-price-config') + return mergeListingSalePriceConfig(data.data) +} + +export function mergeListingPublishOptions(options?: Partial): ListingPublishOptions { + return { + server_options: normalizeStringList(options?.server_options), + face_options: normalizeStringList(options?.face_options), + rank_options: normalizeStringList(options?.rank_options), + insurance_options: normalizeStringList(options?.insurance_options), + level_options: normalizeStringList(options?.level_options), + login_method_options: normalizeStringList(options?.login_method_options), + region_options: normalizeStringList(options?.region_options), + skin_groups: normalizeOptionGroups(options?.skin_groups), + quantity_items: normalizeQuantityItems(options?.quantity_items), + screenshot_slots: normalizeScreenshotSlots(options?.screenshot_slots), + ban_record_options: normalizeStringList(options?.ban_record_options), + ban_evidence_options: normalizeStringList(options?.ban_evidence_options), + fire_level_min: readPositiveInteger(options?.fire_level_min, 38), + price_config: normalizePriceConfig(options?.price_config), + deposit_recommend_config: normalizeDepositRecommendConfig(options?.deposit_recommend_config), + ratio_config: normalizeRatioConfig(options?.ratio_config), + } +} + +export function mergeListingSalePriceConfig(options?: Partial): PublishSalePriceConfig { + return normalizeSalePriceConfig(options) +} + +function normalizeStringList(values?: unknown[]) { + return Array.isArray(values) + ? values.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) + : [] +} + +function normalizeOptionGroups(values?: unknown[]): PublishOptionGroup[] { + if (!Array.isArray(values)) return [] + return values + .map((item) => { + const group = isRecord(item) ? item : {} + return { + key: typeof group.key === 'string' ? group.key.trim() : '', + title: typeof group.title === 'string' ? group.title.trim() : '', + options: normalizeStringList(Array.isArray(group.options) ? group.options : []), + } + }) + .filter((item) => item.key && item.title) +} + +function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] { + if (!Array.isArray(values)) return [] + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + key: typeof row.key === 'string' ? row.key.trim() : '', + label: typeof row.label === 'string' ? row.label.trim() : '', + price: typeof row.price === 'string' ? row.price.trim() : '', + placeholder: typeof row.placeholder === 'string' ? row.placeholder.trim() : undefined, + } + }) + .filter((item) => item.key && item.label) +} + +function normalizeScreenshotSlots(values?: unknown[]): PublishScreenshotSlot[] { + if (!Array.isArray(values)) return [] + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + key: typeof row.key === 'string' ? row.key.trim() : '', + label: typeof row.label === 'string' ? row.label.trim() : '', + required: row.required === true, + hint: typeof row.hint === 'string' ? row.hint.trim() : '', + } + }) + .filter((item) => item.key && item.label) +} + +function normalizePriceConfig(value?: unknown): PublishPriceConfig { + const row = isRecord(value) ? value : {} + return { + deposit_placeholder: typeof row.deposit_placeholder === 'string' ? row.deposit_placeholder.trim() : '', + price_placeholder: typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '', + ratio_description: typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '', + } +} + +function normalizeDepositRecommendConfig(value?: unknown): PublishDepositRecommendConfig { + const row = isRecord(value) ? value : {} + const config = { + base_amount: readNumber(row.base_amount), + skin_group_rules: normalizeDepositSkinGroupRules( + Array.isArray(row.skin_group_rules) ? row.skin_group_rules : [], + ), + } + if (config.base_amount <= 0) { + config.base_amount = emptyListingPublishOptions.deposit_recommend_config.base_amount + } + if (config.skin_group_rules.length === 0) { + config.skin_group_rules = [...emptyListingPublishOptions.deposit_recommend_config.skin_group_rules] + } + return config +} + +function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGroupRule[] { + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '', + label: typeof row.label === 'string' ? row.label.trim() : '', + amount_per_item: readNumber(row.amount_per_item), + } + }) + .filter((item) => item.group_key && item.label && item.amount_per_item >= 0) +} + +function normalizeRatioConfig(value?: unknown): PublishRatioConfig { + const row = isRecord(value) ? value : {} + return { + insurance_base_ratios: normalizeInsuranceBaseRatios( + Array.isArray(row.insurance_base_ratios) ? row.insurance_base_ratios : [], + ), + config_items: normalizeRatioConfigItems( + Array.isArray(row.config_items) ? row.config_items : [], + ), + coin_corrections: normalizeCoinCorrections( + Array.isArray(row.coin_corrections) ? row.coin_corrections : [], + ), + } +} + +function normalizeSalePriceConfig(value?: unknown): PublishSalePriceConfig { + const row = isRecord(value) ? value : {} + const config = { + fixed_markup_rules: normalizeSaleFixedMarkupRules( + Array.isArray(row.fixed_markup_rules) ? row.fixed_markup_rules : [], + ), + ratio_adjustment_rules: normalizeSaleRatioAdjustmentRules( + Array.isArray(row.ratio_adjustment_rules) ? row.ratio_adjustment_rules : [], + ), + } + if (config.fixed_markup_rules.length === 0 && config.ratio_adjustment_rules.length === 0) { + return JSON.parse(JSON.stringify(emptyListingSalePriceConfig)) as PublishSalePriceConfig + } + return config +} + +function normalizeInsuranceBaseRatios(values: unknown[]): PublishInsuranceBaseRatio[] { + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + insurance: typeof row.insurance === 'string' ? row.insurance.trim() : '', + ratio: readNumber(row.ratio), + } + }) + .filter((item) => item.insurance && item.ratio > 0) +} + +function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] { + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + key: typeof row.key === 'string' ? row.key.trim() : '', + label: typeof row.label === 'string' ? row.label.trim() : '', + kind: typeof row.kind === 'string' ? row.kind.trim() : '', + group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '', + missing_penalty: readNumber(row.missing_penalty), + } + }) + .filter((item) => item.key && item.label && item.kind) +} + +function normalizeCoinCorrections(values: unknown[]): PublishCoinCorrection[] { + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + threshold_m: readNumber(row.threshold_m), + correction: readNumber(row.correction), + } + }) + .filter((item) => item.threshold_m >= 0 && item.correction > 0) +} + +function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarkupRule[] { + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + min_m: readNumber(row.min_m), + max_m: readNumber(row.max_m), + markup_amount: readNumber(row.markup_amount), + } + }) + .filter((item) => item.min_m >= 0 && item.max_m >= item.min_m && item.markup_amount >= 0) +} + +function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioAdjustmentRule[] { + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + min_m: readNumber(row.min_m), + max_m: readNumber(row.max_m), + ratio_subtract: readNumber(row.ratio_subtract), + } + }) + .filter( + (item) => + item.min_m >= 0 && + (item.max_m === 0 || item.max_m >= item.min_m) && + item.ratio_subtract >= 0, + ) +} + +function readNumber(value: unknown) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +function readPositiveInteger(value: unknown, fallback: number) { + const parsed = Math.trunc(readNumber(value)) + return parsed > 0 ? parsed : fallback +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/frontend/src/features/listings/api/listings.ts b/frontend/src/features/listings/api/listings.ts new file mode 100644 index 0000000..8a673cb --- /dev/null +++ b/frontend/src/features/listings/api/listings.ts @@ -0,0 +1,194 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' +import type { ListingReviewStatus, ListingStatus } from '@/shared/types/status' + +export interface Listing { + id: number + account_id: number + owner_id: number + owner_phone?: string + owner_nickname?: string + title: string + description: string + game_name: string + server_region: string + login_platform: string + rank_level: string + haf_coin_amount: number + asset_summary?: Record + screenshot_urls: string[] + cover_url: string + price: number + deposit_amount: number + is_accelerated_sale?: boolean + in_transaction: boolean + status: ListingStatus + review_status: ListingReviewStatus + review_reason: string + published_at?: string + created_at: string + updated_at: string +} + +export interface ListingPayload { + title: string + description: string + server_region: string + login_platform: string + rank_level: string + haf_coin_amount: number + asset_summary?: Record + screenshot_urls: string[] + price: number + deposit_amount: number +} + +export interface PublicListingQuery { + page?: number + page_size?: number + keyword?: string + sort?: string + zone?: string + server?: string + region?: string + login_method?: string + rank?: string + insurance?: string + stamina?: string + load?: string + skin_group?: string + skin_name?: string + min_coin?: number + max_coin?: number + min_price?: number + max_price?: number + min_deposit?: number + max_deposit?: number + min_total?: number + max_total?: number + min_fire_level?: number + max_fire_level?: number + min_secret_kd?: number + max_secret_kd?: number + [key: `resource_${string}_min`]: number | undefined + [key: `resource_${string}_max`]: number | undefined +} + +export interface PublicListingPage { + items: Listing[] + total: number + page: number + page_size: number + zone_counts: Record +} + +export async function fetchListings(query: PublicListingQuery = {}) { + const page = await fetchListingsPage(query) + return page.items +} + +export async function fetchListingsPage(query: PublicListingQuery = {}) { + const params = Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== '' && value !== undefined && value !== null) + ) + const { data } = await apiClient.get>>('/listings', { params }) + return normalizePublicListingPage(data.data, query) +} + +function normalizePublicListingPage(data: Partial, query: PublicListingQuery): PublicListingPage { + const items = Array.isArray(data.items) ? data.items : [] + return { + items, + total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length, + page: Number.isFinite(Number(data.page)) ? Number(data.page) : Number(query.page || 1), + page_size: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : Number(query.page_size || items.length || 20), + zone_counts: data.zone_counts && typeof data.zone_counts === 'object' ? data.zone_counts : {}, + } +} + +export async function fetchListing(id: string | number) { + const { data } = await apiClient.get>(`/listings/${id}`) + return data.data +} + +export async function fetchSellerListings() { + const { data } = await apiClient.get>('/seller/listings') + return data.data.items +} + +export async function createListing(payload: ListingPayload) { + const { data } = await apiClient.post>('/listings', payload) + return data.data +} + +export async function submitListingReview(id: number) { + const { data } = await apiClient.post>(`/listings/${id}/submit-review`) + return data.data +} + +export async function offlineListing(id: number) { + const { data } = await apiClient.delete>(`/listings/${id}`) + return data.data +} + +export async function fetchPendingReviewListings() { + const { data } = await apiClient.get>('/admin/listings/pending') + return data.data.items +} + +export interface AdminListingQuery { + owner_id?: string + status?: ListingStatus | '' + review_status?: ListingReviewStatus | '' + limit?: number + page?: number + page_size?: number +} + +export interface AdminListingPage { + items: Listing[] + total: number + page: number + page_size: number +} + +export async function fetchAdminListings(query: AdminListingQuery = {}) { + const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) + const { data } = await apiClient.get>('/admin/listings', { params }) + return normalizeAdminListingPage(data.data, query) +} + +function normalizeAdminListingPage(data: Partial, query: AdminListingQuery): AdminListingPage { + const items = Array.isArray(data.items) ? data.items : [] + return { + items, + total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length, + page: Number.isFinite(Number(data.page)) ? Number(data.page) : Number(query.page || 1), + page_size: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : Number(query.page_size || items.length || 10), + } +} + +export async function fetchAdminListing(id: string | number) { + const { data } = await apiClient.get>(`/admin/listings/${id}`) + return data.data +} + +export async function adminOfflineListing(id: number, reason: string) { + const { data } = await apiClient.post>(`/admin/listings/${id}/offline`, { reason }) + return data.data +} + +export async function adminMarkListingAbnormal(id: number, reason: string) { + const { data } = await apiClient.post>(`/admin/listings/${id}/mark-abnormal`, { reason }) + return data.data +} + +export async function approveListing(id: number) { + const { data } = await apiClient.post>(`/admin/listings/${id}/approve`) + return data.data +} + +export async function rejectListing(id: number, reason: string) { + const { data } = await apiClient.post>(`/admin/listings/${id}/reject`, { reason }) + return data.data +} diff --git a/frontend/src/features/listings/components/HomeAnnouncement.vue b/frontend/src/features/listings/components/HomeAnnouncement.vue new file mode 100644 index 0000000..365d08b --- /dev/null +++ b/frontend/src/features/listings/components/HomeAnnouncement.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/src/features/listings/components/HomeBanner.vue b/frontend/src/features/listings/components/HomeBanner.vue new file mode 100644 index 0000000..2d8c37f --- /dev/null +++ b/frontend/src/features/listings/components/HomeBanner.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/features/listings/components/HomeFilters.vue b/frontend/src/features/listings/components/HomeFilters.vue new file mode 100644 index 0000000..4d02ed3 --- /dev/null +++ b/frontend/src/features/listings/components/HomeFilters.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/frontend/src/features/listings/components/HomeStats.vue b/frontend/src/features/listings/components/HomeStats.vue new file mode 100644 index 0000000..a4c8669 --- /dev/null +++ b/frontend/src/features/listings/components/HomeStats.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/features/listings/components/HomeZonesAndSort.vue b/frontend/src/features/listings/components/HomeZonesAndSort.vue new file mode 100644 index 0000000..f78892e --- /dev/null +++ b/frontend/src/features/listings/components/HomeZonesAndSort.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/features/listings/components/ListingCard.vue b/frontend/src/features/listings/components/ListingCard.vue new file mode 100644 index 0000000..cbfff16 --- /dev/null +++ b/frontend/src/features/listings/components/ListingCard.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/frontend/src/features/listings/components/RangeFilter.vue b/frontend/src/features/listings/components/RangeFilter.vue new file mode 100644 index 0000000..1d8db13 --- /dev/null +++ b/frontend/src/features/listings/components/RangeFilter.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/src/features/listings/components/SkinFilter.vue b/frontend/src/features/listings/components/SkinFilter.vue new file mode 100644 index 0000000..5af5364 --- /dev/null +++ b/frontend/src/features/listings/components/SkinFilter.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/features/listings/components/StringFilter.vue b/frontend/src/features/listings/components/StringFilter.vue new file mode 100644 index 0000000..17ff63f --- /dev/null +++ b/frontend/src/features/listings/components/StringFilter.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts b/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts new file mode 100644 index 0000000..0d77d62 --- /dev/null +++ b/frontend/src/features/listings/composables/__tests__/useFilterOptions.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { rangeLabel, isRangeSelected } from '@/composables/home/useFilterOptions' + +describe('useFilterOptions', () => { + describe('rangeLabel', () => { + it('应该返回双向范围标签', () => { + expect(rangeLabel(100, 500, '哈夫币')).toBe('100-500') + }) + + it('应该返回最小值+标签', () => { + expect(rangeLabel(500, undefined, '哈夫币')).toBe('500+') + }) + + it('应该返回最大值标签', () => { + expect(rangeLabel(undefined, 500, '哈夫币')).toBe('≤500') + }) + + it('应该返回默认标签', () => { + expect(rangeLabel(undefined, undefined, '哈夫币')).toBe('哈夫币') + }) + }) + + describe('isRangeSelected', () => { + it('应该正确判断范围是否选中', () => { + expect(isRangeSelected(100, 500, 100, 500)).toBe(true) + expect(isRangeSelected(100, 500, 200, 500)).toBe(false) + expect(isRangeSelected(undefined, undefined, undefined, undefined)).toBe(true) + }) + }) +}) diff --git a/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts b/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts new file mode 100644 index 0000000..d784cc8 --- /dev/null +++ b/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { useHomeFilters } from '@/composables/home/useHomeFilters' +import { ref } from 'vue' +import { emptyListingPublishOptions } from '@/api/listingOptions' + +describe('useHomeFilters', () => { + it('应该初始化空的筛选器', () => { + const publishOptions = ref(emptyListingPublishOptions) + const listings = ref([]) + + const { filters } = useHomeFilters(publishOptions, listings) + + expect(filters.keyword).toBe('') + expect(filters.minCoin).toBeUndefined() + expect(filters.maxCoin).toBeUndefined() + }) + + it('应该正确重置筛选器', () => { + const publishOptions = ref(emptyListingPublishOptions) + const listings = ref([]) + + const { filters, resetFilters } = useHomeFilters(publishOptions, listings) + + filters.keyword = '测试' + filters.minCoin = 100 + filters.region = '北京' + + resetFilters() + + expect(filters.keyword).toBe('') + expect(filters.minCoin).toBeUndefined() + expect(filters.region).toBe('') + }) + + it('应该正确设置字符串筛选器', () => { + const publishOptions = ref(emptyListingPublishOptions) + const listings = ref([]) + + const { filters, setStringFilter, closeFilterPopover } = useHomeFilters(publishOptions, listings) + + setStringFilter('region', '上海') + + expect(filters.region).toBe('上海') + }) + + it('应该正确设置范围筛选器', () => { + const publishOptions = ref(emptyListingPublishOptions) + const listings = ref([]) + + const { filters, setCoinRange } = useHomeFilters(publishOptions, listings) + + setCoinRange(100, 500) + + expect(filters.minCoin).toBe(100) + expect(filters.maxCoin).toBe(500) + }) +}) diff --git a/frontend/src/features/listings/composables/useFilterOptions.ts b/frontend/src/features/listings/composables/useFilterOptions.ts new file mode 100644 index 0000000..c6a1f76 --- /dev/null +++ b/frontend/src/features/listings/composables/useFilterOptions.ts @@ -0,0 +1,51 @@ +export const coinRangeOptions = [ + { label: '全部区间', min: undefined, max: undefined }, + { label: '0-100', min: 0, max: 100 }, + { label: '100-300', min: 100, max: 300 }, + { label: '300-500', min: 300, max: 500 }, + { label: '500+', min: 500, max: undefined }, +] + +export const moneyRangeOptions = [ + { label: '全部区间', min: undefined, max: undefined }, + { label: '0-50', min: 0, max: 50 }, + { label: '50-100', min: 50, max: 100 }, + { label: '100-200', min: 100, max: 200 }, + { label: '200+', min: 200, max: undefined }, +] + +export const totalRangeOptions = [ + { label: '全部区间', min: undefined, max: undefined }, + { label: '0-500', min: 0, max: 500 }, + { label: '500-1000', min: 500, max: 1000 }, + { label: '1000-2000', min: 1000, max: 2000 }, + { label: '2000-5000', min: 2000, max: 5000 }, +] + +export const fireLevelRangeOptions = [ + { label: '全部等级', min: undefined, max: undefined }, + { label: '38-50', min: 38, max: 50 }, + { label: '50-60', min: 50, max: 60 }, + { label: '60-70', min: 60, max: 70 }, + { label: '70+', min: 70, max: undefined }, +] + +export function rangeLabel( + min: number | undefined, + max: number | undefined, + fallback: string +) { + if (min !== undefined && max !== undefined) return `${min}-${max}` + if (min !== undefined) return `${min}+` + if (max !== undefined) return `≤${max}` + return fallback +} + +export function isRangeSelected( + currentMin: number | undefined, + currentMax: number | undefined, + min: number | undefined, + max: number | undefined +) { + return currentMin === min && currentMax === max +} diff --git a/frontend/src/features/listings/composables/useHomeFilters.ts b/frontend/src/features/listings/composables/useHomeFilters.ts new file mode 100644 index 0000000..4cd235e --- /dev/null +++ b/frontend/src/features/listings/composables/useHomeFilters.ts @@ -0,0 +1,208 @@ +import { reactive, ref, computed, type Ref } from 'vue' +import type { ListingPublishOptions } from '../api/listingOptions' +import type { Listing } from '../api/listings' +import { assetRegions } from '@/shared/utils/listingDisplay' + +export type FilterPopoverKey = + | 'insurance' + | 'stamina' + | 'load' + | 'region' + | 'coin' + | 'price' + | 'deposit' + | 'total' + | 'skin' + | 'rank' + | 'fireLevel' + | 'loginMethod' + +export type StringFilterKey = + | 'insurance' + | 'stamina' + | 'load' + | 'region' + | 'rank' + | 'loginMethod' + +export interface HomeFilters { + keyword: string + server: string + region: string + loginMethod: string + rank: string + insurance: string + stamina: string + load: string + skinGroup: string + skinName: string + minCoin: number | undefined + maxCoin: number | undefined + minPrice: number | undefined + maxPrice: number | undefined + minDeposit: number | undefined + maxDeposit: number | undefined + minTotal: number | undefined + maxTotal: number | undefined + minFireLevel: number | undefined + maxFireLevel: number | undefined +} + +export function useHomeFilters( + publishOptions: Ref, + listings: Ref +) { + const filters = reactive({ + keyword: '', + server: '', + region: '', + loginMethod: '', + rank: '', + insurance: '', + stamina: '', + load: '', + skinGroup: '', + skinName: '', + minCoin: undefined, + maxCoin: undefined, + minPrice: undefined, + maxPrice: undefined, + minDeposit: undefined, + maxDeposit: undefined, + minTotal: undefined, + maxTotal: undefined, + minFireLevel: undefined, + maxFireLevel: undefined, + }) + + const activeFilterPopover = ref('') + + const regionOptions = computed(() => + uniqueOptions([ + ...publishOptions.value.region_options, + ...listings.value.flatMap((item) => assetRegions(item)), + ]) + ) + + const loginMethodOptions = computed(() => + uniqueOptions( + publishOptions.value.login_method_options + .map((item) => item.trim()) + .filter(Boolean) + ) + ) + + const skinFilterGroups = computed(() => { + const preferred = ['operatorRed', 'operatorGold'] + return preferred + .map((key) => publishOptions.value.skin_groups.find((group) => group.key === key)) + .filter((group): group is ListingPublishOptions['skin_groups'][number] => Boolean(group)) + }) + + const skinChipLabel = computed(() => { + if (filters.skinName) return filters.skinName + if (filters.skinGroup) { + return skinFilterGroups.value.find((group) => group.key === filters.skinGroup)?.title || '皮肤' + } + return '皮肤' + }) + + function uniqueOptions(values: string[]) { + return [...new Set(values.map((item) => item.trim()).filter(Boolean))] + } + + function resetFilters() { + filters.keyword = '' + filters.server = '' + filters.region = '' + filters.loginMethod = '' + filters.rank = '' + filters.insurance = '' + filters.stamina = '' + filters.load = '' + filters.skinGroup = '' + filters.skinName = '' + filters.minCoin = undefined + filters.maxCoin = undefined + filters.minPrice = undefined + filters.maxPrice = undefined + filters.minDeposit = undefined + filters.maxDeposit = undefined + filters.minTotal = undefined + filters.maxTotal = undefined + filters.minFireLevel = undefined + filters.maxFireLevel = undefined + } + + function setStringFilter(key: StringFilterKey, value: string) { + filters[key] = value + closeFilterPopover() + } + + function setCoinRange(min: number | undefined, max: number | undefined) { + filters.minCoin = min + filters.maxCoin = max + } + + function setPriceRange(min: number | undefined, max: number | undefined) { + filters.minPrice = min + filters.maxPrice = max + } + + function setDepositRange(min: number | undefined, max: number | undefined) { + filters.minDeposit = min + filters.maxDeposit = max + } + + function setTotalRange(min: number | undefined, max: number | undefined) { + filters.minTotal = min + filters.maxTotal = max + } + + function setFireLevelRange(min: number | undefined, max: number | undefined) { + filters.minFireLevel = min + filters.maxFireLevel = max + } + + function setSkinFilter(group: string, name = '') { + filters.skinGroup = group + filters.skinName = name + } + + function resetSkinFilter() { + filters.skinGroup = '' + filters.skinName = '' + } + + function setFilterPopover(key: FilterPopoverKey, visible: boolean) { + if (visible) { + activeFilterPopover.value = key + return + } + if (activeFilterPopover.value === key) activeFilterPopover.value = '' + } + + function closeFilterPopover() { + activeFilterPopover.value = '' + } + + return { + filters, + activeFilterPopover, + regionOptions, + loginMethodOptions, + skinFilterGroups, + skinChipLabel, + resetFilters, + setStringFilter, + setCoinRange, + setPriceRange, + setDepositRange, + setTotalRange, + setFireLevelRange, + setSkinFilter, + resetSkinFilter, + setFilterPopover, + closeFilterPopover, + } +} diff --git a/frontend/src/features/listings/composables/useListingQuery.ts b/frontend/src/features/listings/composables/useListingQuery.ts new file mode 100644 index 0000000..61f48e7 --- /dev/null +++ b/frontend/src/features/listings/composables/useListingQuery.ts @@ -0,0 +1,122 @@ +import { ref, onMounted, onBeforeUnmount, watch, type Ref } from 'vue' +import { fetchListingsPage, type Listing, type PublicListingQuery } from '../api/listings' +import type { HomeFilters } from './useHomeFilters' + +const homePageSize = 12 + +export function useListingQuery( + filters: HomeFilters, + sortBy: Ref, + activeZone: Ref +) { + const loading = ref(false) + const loadingMore = ref(false) + const listings = ref([]) + const totalListings = ref(0) + const zoneCounts = ref>({}) + const currentPage = ref(1) + const hasMoreListings = ref(true) + let listingRequestSeq = 0 + + function buildListingQuery(page: number): PublicListingQuery { + return { + page, + page_size: homePageSize, + keyword: filters.keyword.trim(), + sort: sortBy.value, + zone: activeZone.value, + server: filters.server, + region: filters.region, + login_method: filters.loginMethod, + rank: filters.rank, + insurance: filters.insurance, + stamina: filters.stamina, + load: filters.load, + skin_group: filters.skinGroup, + skin_name: filters.skinName, + min_coin: filters.minCoin, + max_coin: filters.maxCoin, + min_price: filters.minPrice, + max_price: filters.maxPrice, + min_deposit: filters.minDeposit, + max_deposit: filters.maxDeposit, + min_total: filters.minTotal, + max_total: filters.maxTotal, + min_fire_level: filters.minFireLevel, + max_fire_level: filters.maxFireLevel, + } + } + + function listingQuerySignature() { + return JSON.stringify(buildListingQuery(1)) + } + + async function loadListingsPage(reset = false) { + if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return + const requestSeq = ++listingRequestSeq + if (reset) { + currentPage.value = 1 + hasMoreListings.value = true + } + loadingMore.value = true + try { + const page = await fetchListingsPage(buildListingQuery(currentPage.value)) + if (requestSeq !== listingRequestSeq) return + listings.value = reset ? page.items : [...listings.value, ...page.items] + totalListings.value = page.total + zoneCounts.value = page.zone_counts + hasMoreListings.value = listings.value.length < page.total + currentPage.value = page.page + 1 + requestAnimationFrame(handleWindowScroll) + } catch { + if (reset) { + listings.value = [] + totalListings.value = 0 + zoneCounts.value = {} + hasMoreListings.value = false + } + } finally { + if (requestSeq === listingRequestSeq) { + loadingMore.value = false + } + } + } + + function handleWindowScroll() { + if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 480) return + loadListingsPage(false) + } + + function zoneCount(key: string) { + if (key === 'all') return zoneCounts.value.all ?? totalListings.value + return zoneCounts.value[key] ?? 0 + } + + onMounted(() => { + window.addEventListener('scroll', handleWindowScroll, { passive: true }) + }) + + onBeforeUnmount(() => { + window.removeEventListener('scroll', handleWindowScroll) + }) + + // 使用防抖优化搜索 + let debounceTimer: ReturnType | null = null + watch(() => listingQuerySignature(), () => { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + loadListingsPage(true) + }, 300) + }) + + return { + loading, + loadingMore, + listings, + totalListings, + zoneCounts, + hasMoreListings, + loadListingsPage, + zoneCount, + } +} diff --git a/frontend/src/features/listings/index.ts b/frontend/src/features/listings/index.ts new file mode 100644 index 0000000..8e8603e --- /dev/null +++ b/frontend/src/features/listings/index.ts @@ -0,0 +1,7 @@ +// Listings 模块统一导出 +export * from './api/listings' +export * from './api/listingOptions' +export * from './api/homeConfig' +export * from './composables/useHomeFilters' +export * from './composables/useFilterOptions' +export * from './composables/useListingQuery' diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue new file mode 100644 index 0000000..8af136c --- /dev/null +++ b/frontend/src/features/listings/views/HomeView.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue new file mode 100644 index 0000000..d69604f --- /dev/null +++ b/frontend/src/features/listings/views/ListingDetailView.vue @@ -0,0 +1,749 @@ + + + + + diff --git a/frontend/src/features/listings/views/ListingsView.vue b/frontend/src/features/listings/views/ListingsView.vue new file mode 100644 index 0000000..c261255 --- /dev/null +++ b/frontend/src/features/listings/views/ListingsView.vue @@ -0,0 +1,1389 @@ + + + + + diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue new file mode 100644 index 0000000..84ef6e5 --- /dev/null +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -0,0 +1,541 @@ + + + + + diff --git a/frontend/src/features/listings/views/MobileListingDetailView.vue b/frontend/src/features/listings/views/MobileListingDetailView.vue new file mode 100644 index 0000000..ab54738 --- /dev/null +++ b/frontend/src/features/listings/views/MobileListingDetailView.vue @@ -0,0 +1,733 @@ + + + + +