diff --git a/frontend/src/api/adminAudit.ts b/frontend/src/api/adminAudit.ts deleted file mode 100644 index 3876ce9..0000000 --- a/frontend/src/api/adminAudit.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { apiClient } from './client' - -import type { ApiResponse, PaginatedResult } from './types' - -export interface AdminAuditLog { - id: number - actor_type: string - actor_id: number - actor_username: string - actor_nickname: string - action: string - biz_type: string - biz_id?: number - ip: string - user_agent: string - detail?: Record | null - created_at: string -} - -export interface AdminAuditQuery { - actor_id?: string - action?: string - biz_type?: string - page?: number - page_size?: number -} - -export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) { - const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) - const { data } = await apiClient.get>>('/admin/audit-logs', { params }) - return data.data -} diff --git a/frontend/src/api/adminAuth.ts b/frontend/src/api/adminAuth.ts deleted file mode 100644 index afb9cb8..0000000 --- a/frontend/src/api/adminAuth.ts +++ /dev/null @@ -1,74 +0,0 @@ -import axios from 'axios' - -import { apiClient } from './client' -import type { ApiResponse } from './types' -import { getRefreshToken, setAuthTokens } from '@/utils/authStorage' -import type { UserStatus } from '@/types/status' - -export interface AdminRole { - id: number - code: string - name: string -} - -export interface AdminUser { - id: number - username: string - nickname: string - status: UserStatus - roles: AdminRole[] - permissions: string[] - last_login_at?: string -} - -export interface AdminTokenPair { - access_token: string - refresh_token: string - token_type: string - expires_in: number -} - -export interface AdminLoginData { - admin: AdminUser - tokens: AdminTokenPair -} - -export interface AdminCaptcha { - captcha_id: string - image: string - expires_in: number -} - -export async function fetchAdminCaptcha() { - const { data } = await apiClient.get>('/admin/auth/captcha') - return data.data -} - -export async function loginAdmin(username: string, password: string, captchaId: string, captchaCode: string) { - const { data } = await apiClient.post>('/admin/auth/login', { - username, - password, - captcha_id: captchaId, - captcha_code: captchaCode, - }) - return data.data -} - -export async function fetchAdminMe() { - const { data } = await apiClient.get>('/admin/me') - return data.data -} - -export async function logoutAdmin() { - const { data } = await apiClient.post>('/admin/auth/logout') - return data.data -} - -/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */ -export async function refreshAdminSession() { - const refreshToken = getRefreshToken('admin') - if (!refreshToken) throw new Error('no refresh token') - const { data } = await axios.post>('/api/admin/auth/refresh', { refresh_token: refreshToken }, { timeout: 10000 }) - setAuthTokens('admin', data.data) - return data.data -} diff --git a/frontend/src/api/adminDashboard.ts b/frontend/src/api/adminDashboard.ts deleted file mode 100644 index 2896310..0000000 --- a/frontend/src/api/adminDashboard.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { apiClient } from './client' -import type { ApiResponse } from './types' -import type { DisputeStatus, OrderStatus } from '@/types/status' - -export interface DashboardMetrics { - total_users: number - verified_users: number - total_listings: number - published_listings: number - total_orders: number - renting_orders: number - today_orders: number - today_ledger_amount: number -} - -export interface DashboardPending { - listing_reviews: number - disputes: number - pending_handoffs: number - pending_return_confirms: number -} - -export interface DashboardRecentOrder { - id: number - order_no: string - title: string - renter_id: number - owner_id: number - status: OrderStatus - rent_amount: number - deposit_amount: number - created_at: string -} - -export interface DashboardRecentDispute { - id: number - order_id: number - order_no: string - title: string - type: string - status: DisputeStatus - initiator_id: number - created_at: string -} - -export interface AdminDashboard { - metrics: DashboardMetrics - pending: DashboardPending - recent_orders: DashboardRecentOrder[] - recent_disputes: DashboardRecentDispute[] - generated_at: string -} - -export async function fetchAdminDashboard() { - const { data } = await apiClient.get>('/admin/dashboard') - return { - ...data.data, - recent_orders: data.data.recent_orders ?? [], - recent_disputes: data.data.recent_disputes ?? [], - } -} diff --git a/frontend/src/api/adminMgr.ts b/frontend/src/api/adminMgr.ts deleted file mode 100644 index adba43c..0000000 --- a/frontend/src/api/adminMgr.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { apiClient } from './client' - -import type { ApiResponse, PaginatedResult } from './types' - -export interface AdminRole { - id: number - code: string - name: string - description: string -} - -export interface AdminMgrUser { - id: number - username: string - nickname: string - status: string - roles: AdminRole[] - last_login_at?: string - created_at: string - updated_at: string -} - -export interface CreateAdminRequest { - username: string - password: string - nickname?: string -} - -export interface UpdateAdminRequest { - nickname?: string - status?: string -} - -export interface ChangePasswordRequest { - old_password: string - new_password: string -} - -export async function fetchAdminMgrUsers(page = 1, pageSize = 20) { - const { data } = await apiClient.get>>('/admin/admin-users', { - params: { page, page_size: pageSize }, - }) - return data.data -} - -export async function fetchAdminMgrUser(id: number) { - const { data } = await apiClient.get>(`/admin/admin-users/${id}`) - return data.data -} - -export async function createAdminMgrUser(req: CreateAdminRequest) { - const { data } = await apiClient.post>('/admin/admin-users', req) - return data.data -} - -export async function updateAdminMgrUser(id: number, req: UpdateAdminRequest) { - const { data } = await apiClient.put>(`/admin/admin-users/${id}`, req) - return data.data -} - -export async function deleteAdminMgrUser(id: number) { - const { data } = await apiClient.delete>(`/admin/admin-users/${id}`) - return data.data -} - -export async function assignAdminRoles(id: number, roleIds: number[]) { - const { data } = await apiClient.put>(`/admin/admin-users/${id}/roles`, { - role_ids: roleIds, - }) - return data.data -} - -export async function changeAdminPassword(id: number, req: ChangePasswordRequest) { - const { data } = await apiClient.put>(`/admin/admin-users/${id}/password`, req) - return data.data -} diff --git a/frontend/src/api/adminRoles.ts b/frontend/src/api/adminRoles.ts deleted file mode 100644 index 21f6916..0000000 --- a/frontend/src/api/adminRoles.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { apiClient } from './client' - -import type { ApiResponse } from './types' - -export interface Permission { - id: number - code: string - name: string - resource: string - action: string -} - -export interface Role { - id: number - code: string - name: string - description: string - perm_count: number - permissions?: Permission[] - created_at: string - updated_at: string -} - -export interface CreateRoleRequest { - code: string - name: string - description?: string -} - -export interface UpdateRoleRequest { - name: string - description?: string -} - -export async function fetchRoles() { - const { data } = await apiClient.get>('/admin/roles') - return data.data -} - -export async function fetchRole(id: number) { - const { data } = await apiClient.get>(`/admin/roles/${id}`) - return data.data -} - -export async function createRole(req: CreateRoleRequest) { - const { data } = await apiClient.post>('/admin/roles', req) - return data.data -} - -export async function updateRole(id: number, req: UpdateRoleRequest) { - const { data } = await apiClient.put>(`/admin/roles/${id}`, req) - return data.data -} - -export async function deleteRole(id: number) { - const { data } = await apiClient.delete>(`/admin/roles/${id}`) - return data.data -} - -export async function assignRolePermissions(roleId: number, permissionIds: number[]) { - const { data } = await apiClient.put>(`/admin/roles/${roleId}/permissions`, { - permission_ids: permissionIds, - }) - return data.data -} - -export async function fetchPermissions() { - const { data } = await apiClient.get>('/admin/permissions') - return data.data -} diff --git a/frontend/src/api/adminUsers.ts b/frontend/src/api/adminUsers.ts deleted file mode 100644 index dbc396c..0000000 --- a/frontend/src/api/adminUsers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { apiClient } from './client' - -import type { ApiResponse, PaginatedResult } from './types' -import type { RealnameStatusValue, RiskStatus, UserStatus } from '@/types/status' - -export interface AdminUserItem { - id: number - phone: string - nickname: string - realname_status: RealnameStatusValue - risk_status: RiskStatus - credit_score: number - status: UserStatus - order_count: number - listing_count: number - dispute_count: number - last_login_at?: string - created_at: string - updated_at: string -} - -export async function fetchAdminUsers(page = 1, pageSize = 20) { - const { data } = await apiClient.get>>('/admin/users', { - params: { page, page_size: pageSize }, - }) - return data.data -} - -export async function freezeAdminUser(id: number, reason: string) { - const { data } = await apiClient.post>(`/admin/users/${id}/freeze`, { reason }) - return data.data -} - -export async function unfreezeAdminUser(id: number) { - const { data } = await apiClient.post>(`/admin/users/${id}/unfreeze`) - return data.data -} diff --git a/frontend/src/api/adminWallet.ts b/frontend/src/api/adminWallet.ts deleted file mode 100644 index ff02d08..0000000 --- a/frontend/src/api/adminWallet.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { apiClient } from './client' - -import type { ApiResponse, PaginatedResult } from './types' -import type { BalanceType, LedgerDirection } from '@/types/status' - -export interface AdminWalletLedger { - id: number - ledger_no: string - user_id: number - user_phone: string - user_nickname: string - order_id?: number - order_no: string - direction: LedgerDirection - amount: number - balance_after: number - balance_type: BalanceType - biz_type: string - biz_no: string - remark: string - created_at: string -} - -export interface AdminWalletLedgerQuery { - user_id?: string - order_id?: string - biz_type?: string - page?: number - page_size?: number -} - -export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) { - const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) - const { data } = await apiClient.get>>('/admin/wallet/ledger', { params }) - return data.data -} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts deleted file mode 100644 index a70ee69..0000000 --- a/frontend/src/api/auth.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { apiClient } from './client' -import type { ApiResponse } from './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/api/chats.ts b/frontend/src/api/chats.ts deleted file mode 100644 index 0fa92de..0000000 --- a/frontend/src/api/chats.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { apiClient } from './client' -import type { ApiResponse, PaginatedResult } from './types' - -export interface ChatParticipant { - id: number - conversation_id: number - participant_type: 'user' | 'admin' - participant_id: number - role: 'renter' | 'owner' | 'support' | 'customer' - remark: string - display_name: string - avatar_url: string - last_read_at?: string - joined_at: string -} - -export interface ChatConversation { - id: number - order_id: number | null - type: string - title: string - status: string - role: 'renter' | 'owner' | 'support' | 'customer' - participants?: ChatParticipant[] - last_message_id?: number - last_message_preview: string - last_message_at?: string - unread_count: number - created_at: string - updated_at: string -} - -export interface ChatMessage { - id: number - conversation_id: number - sender_type: 'user' | 'admin' | 'system' - sender_id: number - sender_role: 'renter' | 'owner' | 'support' | 'customer' | 'system' - sender_name: string - sender_avatar: string - is_self: boolean - content_type: 'text' | 'system' - content: string - attachment_urls: string[] - created_at: string -} - -export async function fetchChats(page = 1, pageSize = 20) { - const { data } = await apiClient.get>>('/chats', { - params: { page, page_size: pageSize }, - }) - return data.data -} - -export async function fetchChat(id: number) { - const { data } = await apiClient.get>(`/chats/${id}`) - return data.data -} - -export async function fetchOrderChat(orderId: number) { - const { data } = await apiClient.get>(`/orders/${orderId}/chat`) - return data.data -} - -export async function ensureSupportChat() { - const { data } = await apiClient.post>('/chats/support') - return data.data -} - -export async function fetchChatMessages(id: number, page = 1, pageSize = 100) { - const { data } = await apiClient.get>>(`/chats/${id}/messages`, { - params: { page, page_size: pageSize }, - }) - return data.data -} - -export async function sendChatMessage(id: number, content: string, attachmentUrls: string[] = []) { - const { data } = await apiClient.post>(`/chats/${id}/messages`, { - content, - attachment_urls: attachmentUrls, - }) - return data.data -} - -export async function markChatRead(id: number) { - const { data } = await apiClient.post>(`/chats/${id}/read`) - return data.data -} - -export async function fetchAdminChats(page = 1, pageSize = 50, filter = 'all') { - const { data } = await apiClient.get>>('/admin/chats', { - params: { page, page_size: pageSize, filter }, - }) - return data.data -} - -export async function fetchAdminChat(id: number) { - const { data } = await apiClient.get>(`/admin/chats/${id}`) - return data.data -} - -export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 100) { - const { data } = await apiClient.get>>(`/admin/chats/${id}/messages`, { - params: { page, page_size: pageSize }, - }) - return data.data -} - -export async function sendAdminChatMessage(id: number, content: string, attachmentUrls: string[] = []) { - const { data } = await apiClient.post>(`/admin/chats/${id}/messages`, { - content, - attachment_urls: attachmentUrls, - }) - return data.data -} - -export async function markAdminChatRead(id: number) { - const { data } = await apiClient.post>(`/admin/chats/${id}/read`) - return data.data -} - -export interface SupportAdmin { - id: number - nickname: string - chat_count: number -} - -export async function fetchSupportAdmins() { - const { data } = await apiClient.get>('/admin/chats/support-admins') - return data.data -} - -export async function transferChat(id: number, toAdminId: number) { - const { data } = await apiClient.post>(`/admin/chats/${id}/transfer`, { - to_admin_id: toAdminId, - }) - return data.data -} - -export async function updateChatRemark(id: number, remark: string) { - const { data } = await apiClient.put>(`/admin/chats/${id}/remark`, { remark }) - return data.data -} - -export interface QuickReply { - id: number - admin_user_id: number - title: string - content: string - sort_order: number - is_global: boolean -} - -export async function fetchQuickReplies() { - const { data } = await apiClient.get>('/admin/chats/quick-replies') - return data.data -} - -export async function createQuickReply(title: string, content: string, sortOrder = 0, isGlobal = false) { - const { data } = await apiClient.post>('/admin/chats/quick-replies', { - title, - content, - sort_order: sortOrder, - is_global: isGlobal, - }) - return data.data -} - -export async function updateQuickReply(id: number, updates: { title?: string; content?: string; sort_order?: number }) { - const { data } = await apiClient.put>(`/admin/chats/quick-replies/${id}`, updates) - return data.data -} - -export async function deleteQuickReply(id: number) { - const { data } = await apiClient.delete>(`/admin/chats/quick-replies/${id}`) - return data.data -} - -export async function fetchAutoWelcomeMessage() { - const { data } = await apiClient.get>('/admin/chats/auto-welcome') - return data.data.message -} - -export async function updateAutoWelcomeMessage(message: string) { - const { data } = await apiClient.put>('/admin/chats/auto-welcome', { message }) - return data.data -} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 23896ed..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,172 +0,0 @@ -import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' -import { ElMessage } from 'element-plus' -import { showToast } from 'vant' - -function showError(message: string) { - const isMobile = window.location.pathname.startsWith('/m') - if (isMobile) { - showToast({ message, icon: 'cross' }) - } else { - ElMessage.error(message) - } -} - -declare module 'axios' { - export interface AxiosRequestConfig { - silent?: boolean - } -} - -import { - clearAuthStorage, - getAccessToken, - getLoginPath, - getRefreshToken, - setAuthTokens, - type AuthScope, -} from '@/utils/authStorage' -import type { ApiResponse } from './types' - -export const apiClient = axios.create({ - baseURL: '/api', - timeout: 10000, -}) - -export async function unwrapData(request: Promise>>) { - const { data } = await request - return data.data -} - -type RetryRequest = { - resolve: (token: string) => void - reject: (error: unknown) => void -} - -type RefreshState = { - refreshing: boolean - pendingRequests: RetryRequest[] -} - -type RetriableRequestConfig = InternalAxiosRequestConfig & { - _retry?: boolean - silent?: boolean -} - -const refreshStates: Record = { - user: { - refreshing: false, - pendingRequests: [], - }, - admin: { - refreshing: false, - pendingRequests: [], - }, -} - -function resolvePendingRequests(scope: AuthScope, token: string) { - const pendingRequests = refreshStates[scope].pendingRequests.splice(0) - pendingRequests.forEach(({ resolve }) => resolve(token)) -} - -function rejectPendingRequests(scope: AuthScope, error: unknown) { - const pendingRequests = refreshStates[scope].pendingRequests.splice(0) - pendingRequests.forEach(({ reject }) => reject(error)) -} - -export async function refreshAccessToken(scope: AuthScope): Promise { - const refreshToken = getRefreshToken(scope) - if (!refreshToken) throw new Error('no refresh token') - - const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh' - const { data } = await axios.post(endpoint, { refresh_token: refreshToken }, { timeout: 10000 }) - const tokens = { - access_token: data.data.access_token, - refresh_token: data.data.refresh_token, - } - setAuthTokens(scope, tokens) - return tokens.access_token -} - -function getRequestScope(url = ''): AuthScope { - return url.startsWith('/admin') ? 'admin' : 'user' -} - -function redirectToLogin(scope: AuthScope) { - clearAuthStorage(scope) - const currentPath = window.location.pathname + window.location.search - const loginPath = getLoginPath(scope, currentPath) - if (currentPath.startsWith(loginPath)) return - - if (scope === 'admin') { - window.location.assign(loginPath) - return - } - - window.location.assign(`${loginPath}?redirect=${encodeURIComponent(currentPath)}`) -} - -function isRefreshRequest(url = '') { - return url.endsWith('/auth/refresh') || url.endsWith('/admin/auth/refresh') -} - -apiClient.interceptors.request.use((config) => { - const scope = getRequestScope(config.url || '') - const token = getAccessToken(scope) - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config -}) - -apiClient.interceptors.response.use( - (response) => response, - async (error) => { - const originalRequest = error.config as RetriableRequestConfig | undefined - if (!originalRequest || error?.response?.status !== 401 || originalRequest._retry) { - if (originalRequest && !originalRequest.silent) { - const msg = error.response?.data?.message || error.message || '网络连接异常,请稍后重试' - showError(msg) - } - return Promise.reject(error) - } - - const requestUrl = originalRequest.url || '' - const scope = getRequestScope(requestUrl) - if (isRefreshRequest(requestUrl)) { - redirectToLogin(scope) - if (originalRequest && !originalRequest.silent) { - showError('登录已失效,请重新登录') - } - return Promise.reject(error) - } - - const state = refreshStates[scope] - if (state.refreshing) { - return new Promise((resolve, reject) => { - state.pendingRequests.push({ resolve, reject }) - }).then((newToken) => { - originalRequest._retry = true - originalRequest.headers.Authorization = `Bearer ${newToken}` - return apiClient(originalRequest) - }) - } - - state.refreshing = true - try { - const newToken = await refreshAccessToken(scope) - resolvePendingRequests(scope, newToken) - originalRequest._retry = true - originalRequest.headers.Authorization = `Bearer ${newToken}` - return apiClient(originalRequest) - } catch (refreshError) { - rejectPendingRequests(scope, refreshError) - redirectToLogin(scope) - if (originalRequest && !originalRequest.silent) { - showError('会话已过期,请重新登录') - } - return Promise.reject(refreshError) - } finally { - state.refreshing = false - } - }, -) diff --git a/frontend/src/api/disputes.ts b/frontend/src/api/disputes.ts deleted file mode 100644 index 99297f5..0000000 --- a/frontend/src/api/disputes.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { apiClient } from './client' - -import type { ApiResponse, PaginatedResult } from './types' -import type { DisputeStatus } from '@/types/status' - -export interface Dispute { - id: number - order_id: number - order_no: string - title: string - initiator_id: number - target_user_id: number - type: string - status: DisputeStatus - description: string - evidence_urls?: string[] - arbitration_result: string - arbitration_remark: string - handled_by?: number - handled_at?: string - created_at: string - updated_at: string -} - -export async function createDispute(orderId: number, payload: { type: string; description: string; evidence_urls?: string[] }) { - const { data } = await apiClient.post>(`/orders/${orderId}/dispute`, payload) - return data.data -} - -export async function fetchDisputes(page = 1, pageSize = 20) { - const { data } = await apiClient.get>>('/disputes', { - params: { page, page_size: pageSize }, - }) - return data.data -} - -export async function fetchAdminDisputes(page = 1, pageSize = 20) { - const { data } = await apiClient.get>>('/admin/disputes', { - params: { page, page_size: pageSize }, - }) - return data.data -} - -export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) { - const { data } = await apiClient.post>(`/admin/disputes/${id}/arbitrate`, payload) - return data.data -} diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts deleted file mode 100644 index 1c6d8dc..0000000 --- a/frontend/src/api/files.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { apiClient } from './client' -import type { ApiResponse } from './types' -import { optimizeImageForUpload } from '@/utils/imageUpload' - -export interface UploadedFile { - object_key: string - url: string - thumbnail_url?: string - medium_url?: string - filename: string - content_type: string - size: number -} - -export async function uploadFile(file: File, scene: string) { - const uploadTarget = await optimizeImageForUpload(file, scene) - const form = new FormData() - form.append('file', uploadTarget) - form.append('scene', scene) - const { data } = await apiClient.post>('/files/upload', form, { - headers: { 'Content-Type': 'multipart/form-data' }, - }) - return data.data -} - -export async function uploadAdminFile(file: File, scene: string) { - const uploadTarget = await optimizeImageForUpload(file, scene) - const form = new FormData() - form.append('file', uploadTarget) - form.append('scene', scene) - const { data } = await apiClient.post>('/admin/files/upload', form, { - headers: { 'Content-Type': 'multipart/form-data' }, - }) - return data.data -} - -export async function fetchAdminFileBlob(key: string) { - const { data } = await apiClient.get('/admin/files/object', { - params: { key }, - responseType: 'blob', - }) - return data -} - -export async function fetchFileBlobByURL(fileURL: string) { - const apiPath = fileURL.startsWith('/api/') ? fileURL.slice(4) : fileURL - const { data } = await apiClient.get(apiPath, { - responseType: 'blob', - }) - return data -} diff --git a/frontend/src/api/homeConfig.ts b/frontend/src/api/homeConfig.ts deleted file mode 100644 index c6bb1b6..0000000 --- a/frontend/src/api/homeConfig.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { apiClient } from './client' -import type { ApiResponse } from './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/api/listingOptions.ts b/frontend/src/api/listingOptions.ts deleted file mode 100644 index c504c87..0000000 --- a/frontend/src/api/listingOptions.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { apiClient } from './client' -import type { ApiResponse } from './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/api/listings.ts b/frontend/src/api/listings.ts deleted file mode 100644 index 1b52665..0000000 --- a/frontend/src/api/listings.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { apiClient } from './client' -import type { ApiResponse } from './types' -import type { ListingReviewStatus, ListingStatus } from '@/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/api/notifications.ts b/frontend/src/api/notifications.ts deleted file mode 100644 index 91e3c57..0000000 --- a/frontend/src/api/notifications.ts +++ /dev/null @@ -1,27 +0,0 @@ -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/api/orders.ts b/frontend/src/api/orders.ts deleted file mode 100644 index b98e49b..0000000 --- a/frontend/src/api/orders.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { apiClient } from './client' -import type { ApiResponse } from './types' -import type { HandoffStatus, OrderStatus, SettlementStatus } from '@/types/status' - -export interface Order { - id: number - order_no: string - listing_id: number - account_id: number - owner_id: number - renter_id: number - owner_phone?: string - renter_phone?: string - title: string - server_region: string - login_platform: string - rented_at?: string - estimated_duration_hours: number - price_role?: 'renter' | 'owner' | 'admin' | string - display_amount: number - rent_amount?: number - owner_rent_amount?: number - deposit_amount: number - platform_fee?: number - account_snapshot?: Record - status: OrderStatus - handoff_status: HandoffStatus - settlement_status: SettlementStatus - checkout?: Checkout - created_at: string - updated_at: string -} - -export interface Checkout { - id: number - order_id: number - initiated_by: number - status: SettlementStatus - price_role?: 'renter' | 'owner' | 'admin' | string - display_amount: number - rent_amount?: number - owner_rent_amount?: number - platform_fee?: number - deposit_amount: number - consumable_amount: number - coin_consumed_m: number - other_amount: number - deposit_deduct_amount: number - renter_refund_amount?: number - owner_income_amount?: number - content: string - evidence_urls: string[] - owner_adjustment_reason: string - owner_adjusted_at?: string - renter_confirmed_at?: string - renter_rejected_at?: string - created_at: string - updated_at: string -} - -export interface HandoffRecord { - id: number - order_id: number - from_user_id: number - to_user_id: number - type: string - content: string - confirmed_by_renter_at?: string - confirmed_by_owner_at?: string - created_at: string -} - -export interface PaymentOrder { - id: number - payment_no: string - order_id: number - order_no: string - provider: string - third_order_id: string - provider_order_id: string - pay_way: string - jspay_flag: string - amount_cent: number - status: string - td_code?: string - jspay_url?: string - jspay_info?: string - paid: boolean - paid_at?: string - created_at: string - updated_at: string -} - -export interface PayOrderResult { - paid: boolean -} - -export interface SubmitCheckoutPayload { - content: string - consumable_amount: number - coin_consumed_m: number - other_amount: number - evidence_urls: string[] -} - -export interface CounterCheckoutPayload { - consumable_amount: number - coin_consumed_m: number - other_amount: number - deposit_deduct_amount: number - reason: string - evidence_urls: string[] -} - -export async function createOrder(listingId: number) { - const { data } = await apiClient.post>('/orders', { - listing_id: listingId, - }) - return data.data -} - -export async function payOrder(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/pay`) - return data.data -} - -export async function fetchOrders() { - const { data } = await apiClient.get>('/orders') - return data.data.items -} - -export async function fetchAdminOrders() { - const { data } = await apiClient.get>('/admin/orders') - return data.data.items -} - -export async function fetchOrder(id: string | number) { - const { data } = await apiClient.get>(`/orders/${id}`) - return data.data -} - -export async function fetchAdminOrder(id: string | number) { - const { data } = await apiClient.get>(`/admin/orders/${id}`) - return data.data -} - -export async function cancelOrder(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/cancel`) - return data.data -} - -export async function submitHandoff(id: number, content: string) { - const { data } = await apiClient.post>(`/orders/${id}/handoff`, { content }) - return data.data -} - -export async function fetchHandoffRecords(id: string | number) { - const { data } = await apiClient.get>(`/orders/${id}/handoff-records`) - return data.data.items -} - -export async function fetchAdminHandoffRecords(id: string | number) { - const { data } = await apiClient.get>(`/admin/orders/${id}/handoff-records`) - return data.data.items -} - -export async function adminCloseOrder(id: number, reason: string) { - const { data } = await apiClient.post>(`/admin/orders/${id}/close`, { reason }) - return data.data -} - -export async function adminMarkOrderAbnormal(id: number, reason: string) { - const { data } = await apiClient.post>(`/admin/orders/${id}/mark-abnormal`, { reason }) - return data.data -} - -export interface RefundStatus { - order_id: number - order_no: string - refund_status: string - refund_amount_cent: number - refunded_at?: string - total_amount: number -} - -export async function adminRefundOrder(id: number) { - const { data } = await apiClient.post>(`/admin/orders/${id}/refund`) - return data.data -} - -export async function adminRefundStatus(id: number) { - const { data } = await apiClient.get>(`/admin/orders/${id}/refund-status`) - return data.data -} - -export interface StartOrderPaymentRequest { - pay_way?: string - jspay_flag?: string -} - -export async function startOrderPayment(orderId: number, req?: StartOrderPaymentRequest) { - const { data } = await apiClient.post>(`/orders/${orderId}/start-payment`, req || {}) - return data.data -} - -export async function queryOrderPayment(orderId: number) { - const { data } = await apiClient.get>(`/orders/${orderId}/query-payment`) - return data.data -} - -export async function confirmReceive(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/confirm-receive`) - return data.data -} - -export async function submitReturn(id: number, content: string) { - const { data } = await apiClient.post>(`/orders/${id}/return`, { content }) - return data.data -} - -export async function confirmReturn(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/confirm-return`) - return data.data -} - -export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) { - const { data } = await apiClient.post>(`/orders/${id}/checkout`, payload) - return data.data -} - -export async function confirmCheckout(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/checkout/confirm`) - return data.data -} - -export async function counterCheckout(id: number, payload: CounterCheckoutPayload) { - const { data } = await apiClient.post>(`/orders/${id}/checkout/counter`, payload) - return data.data -} - -export async function acceptCheckout(id: number) { - const { data } = await apiClient.post>(`/orders/${id}/checkout/accept`) - return data.data -} diff --git a/frontend/src/api/realname.ts b/frontend/src/api/realname.ts deleted file mode 100644 index e6a49e7..0000000 --- a/frontend/src/api/realname.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { apiClient } from './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/api/systemConfigs.ts b/frontend/src/api/systemConfigs.ts deleted file mode 100644 index 45b6086..0000000 --- a/frontend/src/api/systemConfigs.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { apiClient } from './client' -import type { ApiResponse } from './types' - -export interface SystemConfig { - id: number - key: string - value: string - description: string - updated_by?: number - created_at: string - updated_at: string -} - -export async function fetchSystemConfigs() { - const { data } = await apiClient.get>('/admin/system-configs') - return data.data.items -} - -export async function updateSystemConfig(key: string, payload: { value: string; description?: string }) { - const { data } = await apiClient.put>(`/admin/system-configs/${key}`, payload) - return data.data -} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts deleted file mode 100644 index 1c66d49..0000000 --- a/frontend/src/api/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface ApiResponse { - code: string - message: string - data: T -} - -export interface PaginatedResult { - items: T[] - total: number - page: number - page_size: number -} diff --git a/frontend/src/api/wallet.ts b/frontend/src/api/wallet.ts deleted file mode 100644 index 89dcaaf..0000000 --- a/frontend/src/api/wallet.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { apiClient } from './client' - -import type { ApiResponse, PaginatedResult } from './types' -import type { BalanceType, LedgerDirection, WalletStatus } from '@/types/status' -import type { PaymentOrder } from './orders' - -export interface WalletAccount { - user_id: number - available_balance: number - frozen_balance: number - status: WalletStatus -} - -export interface WalletLedger { - id: number - ledger_no: string - user_id: number - order_id?: number - direction: LedgerDirection - amount: number - balance_after: number - balance_type: BalanceType - biz_type: string - biz_no: string - remark: string - created_at: string -} - -export async function fetchWalletBalance() { - const { data } = await apiClient.get>('/wallet/balance') - return data.data -} - -export async function fetchWalletLedger(page = 1, pageSize = 20) { - const { data } = await apiClient.get>>('/wallet/ledger', { - params: { page, page_size: pageSize }, - }) - return data.data -} - -export async function rechargeWallet(amount: number) { - const { data } = await apiClient.post>('/wallet/recharge', { amount }) - return data.data -} - -export async function startWalletRechargePayment(amount: number) { - const { data } = await apiClient.post>('/wallet/recharge/pay', { amount }) - return data.data -} - -export async function queryWalletRechargePayment(id: number) { - const { data } = await apiClient.post>(`/wallet/recharge/pay/${id}/query`) - return data.data -} diff --git a/frontend/src/composables/home/__tests__/useFilterOptions.spec.ts b/frontend/src/composables/home/__tests__/useFilterOptions.spec.ts deleted file mode 100644 index 0d77d62..0000000 --- a/frontend/src/composables/home/__tests__/useFilterOptions.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/composables/home/__tests__/useHomeFilters.spec.ts b/frontend/src/composables/home/__tests__/useHomeFilters.spec.ts deleted file mode 100644 index d784cc8..0000000 --- a/frontend/src/composables/home/__tests__/useHomeFilters.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -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/composables/home/useFilterOptions.ts b/frontend/src/composables/home/useFilterOptions.ts deleted file mode 100644 index c6a1f76..0000000 --- a/frontend/src/composables/home/useFilterOptions.ts +++ /dev/null @@ -1,51 +0,0 @@ -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/composables/home/useHomeFilters.ts b/frontend/src/composables/home/useHomeFilters.ts deleted file mode 100644 index 67f084e..0000000 --- a/frontend/src/composables/home/useHomeFilters.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { reactive, ref, computed, type Ref } from 'vue' -import type { ListingPublishOptions } from '@/api/listingOptions' -import type { Listing } from '@/api/listings' -import { assetRegions } from '@/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/composables/home/useListingQuery.ts b/frontend/src/composables/home/useListingQuery.ts deleted file mode 100644 index 483b048..0000000 --- a/frontend/src/composables/home/useListingQuery.ts +++ /dev/null @@ -1,122 +0,0 @@ -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/composables/order/useOrderDetail.ts b/frontend/src/composables/order/useOrderDetail.ts deleted file mode 100644 index 15c0dbe..0000000 --- a/frontend/src/composables/order/useOrderDetail.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { computed, ref, onMounted, onBeforeUnmount, type Ref } from 'vue' -import { useRoute, useRouter } from 'vue-router' -import type { - Order, - HandoffRecord, - PaymentOrder, -} from '@/api/orders' -import { - fetchOrder, - fetchHandoffRecords, - cancelOrder, - startOrderPayment, - queryOrderPayment, - submitHandoff, - confirmReceive, - submitCheckout, - acceptCheckout, - counterCheckout, - confirmCheckout, -} from '@/api/orders' -import { createDispute } from '@/api/disputes' -import { uploadFile } from '@/api/files' -import { fetchOrderChat } from '@/api/chats' -import { useSessionStore } from '@/stores/session' - -export interface CheckoutForm { - content: string - consumable_amount: number - coin_consumed_m: number - other_amount: number - evidenceText: string -} - -export interface CounterForm { - consumable_amount: number - coin_consumed_m: number - other_amount: number - deposit_deduct_amount: number - reason: string - evidenceText: string -} - -export function useOrderDetail() { - const route = useRoute() - const router = useRouter() - const session = useSessionStore() - - // Loading states - const loading = ref(false) - const cancelling = ref(false) - const startingPayment = ref(false) - const handoffing = ref(false) - const confirming = ref(false) - const returning = ref(false) - const completing = ref(false) - const countering = ref(false) - const acceptingCheckout = ref(false) - const rejectingCheckout = ref(false) - const disputing = ref(false) - const uploadingEvidence = ref(false) - const openingChat = ref(false) - - // Data - const order = ref(null) - const handoffRecords = ref([]) - const handoffContent = ref('') - - // Payment states - const activePayment = ref(null) - const paymentQRCodeURL = ref('') - const qrGenerating = ref(false) - const checkingPayment = ref(false) - let paymentPollingTimer: number | undefined - let autoPayHandled = false - - // Forms - const checkoutForm = ref({ - content: '', - consumable_amount: 0, - coin_consumed_m: 0, - other_amount: 0, - evidenceText: '', - }) - - const resourceUsage = ref>({}) - - const counterForm = ref({ - consumable_amount: 0, - coin_consumed_m: 0, - other_amount: 0, - deposit_deduct_amount: 0, - reason: '', - evidenceText: '', - }) - - const rejectReason = ref('') - const disputeType = ref('cannot_login') - const disputeDescription = ref('') - const disputeEvidenceText = ref('') - - // Computed - const isOwner = computed(() => order.value?.owner_id === session.userId) - const isRenter = computed(() => order.value?.renter_id === session.userId) - const orderAmountLabel = computed(() => (isOwner.value ? '我的租金' : '订单金额')) - - const canOpenDispute = computed(() => { - if (!order.value || (!isOwner.value && !isRenter.value)) return false - return !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', 'abnormal'].includes( - order.value.status - ) - }) - - const isCheckoutDisputeStage = computed(() => { - return !!order.value && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status) - }) - - // Methods - async function loadOrder() { - loading.value = true - try { - order.value = await fetchOrder(String(route.params.id)) - handoffRecords.value = await fetchHandoffRecords(String(route.params.id)) - return order.value - } finally { - loading.value = false - } - } - - async function handleCancel() { - if (!order.value) return - cancelling.value = true - try { - await cancelOrder(order.value.id) - return true - } finally { - cancelling.value = false - } - } - - async function handlePay() { - if (!order.value) return null - startingPayment.value = true - try { - const payment = await startOrderPayment(order.value.id) - return payment - } finally { - startingPayment.value = false - } - } - - async function handleSubmitHandoff() { - if (!order.value || !handoffContent.value.trim()) return - handoffing.value = true - try { - await submitHandoff(order.value.id, handoffContent.value.trim()) - handoffContent.value = '' - await loadOrder() - return true - } finally { - handoffing.value = false - } - } - - async function handleConfirmReceive() { - if (!order.value) return - confirming.value = true - try { - await confirmReceive(order.value.id) - await loadOrder() - return true - } finally { - confirming.value = false - } - } - - async function handleSubmitCheckout() { - if (!order.value) return - returning.value = true - try { - await submitCheckout(order.value.id, { - content: checkoutForm.value.content.trim(), - consumable_amount: checkoutForm.value.consumable_amount, - coin_consumed_m: checkoutForm.value.coin_consumed_m, - other_amount: checkoutForm.value.other_amount, - evidence: checkoutForm.value.evidenceText.trim(), - }) - await loadOrder() - return true - } finally { - returning.value = false - } - } - - async function handleAcceptCheckout() { - if (!order.value) return - acceptingCheckout.value = true - try { - await acceptCheckout(order.value.id) - await loadOrder() - return true - } finally { - acceptingCheckout.value = false - } - } - - async function handleCounterCheckout() { - if (!order.value) return - countering.value = true - try { - await counterCheckout(order.value.id, { - consumable_amount: counterForm.value.consumable_amount, - coin_consumed_m: counterForm.value.coin_consumed_m, - other_amount: counterForm.value.other_amount, - deposit_deduct_amount: counterForm.value.deposit_deduct_amount, - reason: counterForm.value.reason.trim(), - evidence: counterForm.value.evidenceText.trim(), - }) - await loadOrder() - return true - } finally { - countering.value = false - } - } - - async function handleRejectCheckout(reason: string) { - if (!order.value) return - rejectingCheckout.value = true - try { - await counterCheckout(order.value.id, { - consumable_amount: 0, - coin_consumed_m: 0, - other_amount: 0, - deposit_deduct_amount: 0, - reason: reason.trim(), - evidence: '', - }) - await loadOrder() - return true - } finally { - rejectingCheckout.value = false - } - } - - async function handleConfirmCheckout() { - if (!order.value) return - completing.value = true - try { - await confirmCheckout(order.value.id) - await loadOrder() - return true - } finally { - completing.value = false - } - } - - async function handleCreateDispute() { - if (!order.value) return - disputing.value = true - try { - await createDispute({ - order_id: order.value.id, - type: disputeType.value, - description: disputeDescription.value.trim(), - evidence: disputeEvidenceText.value.trim(), - }) - await loadOrder() - return true - } finally { - disputing.value = false - } - } - - async function handleUploadEvidence(file: File) { - uploadingEvidence.value = true - try { - const result = await uploadFile(file) - return result.url - } finally { - uploadingEvidence.value = false - } - } - - async function handleOpenChat() { - if (!order.value) return - openingChat.value = true - try { - const chat = await fetchOrderChat(order.value.id) - return chat - } finally { - openingChat.value = false - } - } - - function startPaymentPolling() { - stopPaymentPolling() - paymentPollingTimer = window.setInterval(checkPaymentStatus, 2000) - } - - function stopPaymentPolling() { - if (paymentPollingTimer !== undefined) { - clearInterval(paymentPollingTimer) - paymentPollingTimer = undefined - } - } - - async function checkPaymentStatus() { - if (!activePayment.value || checkingPayment.value) return - checkingPayment.value = true - try { - const updated = await queryOrderPayment(activePayment.value.id) - if (updated.paid) { - stopPaymentPolling() - activePayment.value = null - await loadOrder() - return true - } - } catch { - // ignore - } finally { - checkingPayment.value = false - } - return false - } - - function getOrderStep(status: string) { - const stepMap: Record = { - pending_payment: 0, - pending_handoff: 1, - renting: 2, - overdue: 2, - pending_checkout_confirm: 3, - pending_checkout_accept: 3, - completed: 4, - cancelled: 0, - closed: 4, - } - return stepMap[status] ?? 0 - } - - onMounted(loadOrder) - onBeforeUnmount(stopPaymentPolling) - - return { - // States - loading, - cancelling, - startingPayment, - handoffing, - confirming, - returning, - completing, - countering, - acceptingCheckout, - rejectingCheckout, - disputing, - uploadingEvidence, - openingChat, - - // Data - order, - handoffRecords, - handoffContent, - - // Payment - activePayment, - paymentQRCodeURL, - qrGenerating, - checkingPayment, - - // Forms - checkoutForm, - resourceUsage, - counterForm, - rejectReason, - disputeType, - disputeDescription, - disputeEvidenceText, - - // Computed - isOwner, - isRenter, - orderAmountLabel, - canOpenDispute, - isCheckoutDisputeStage, - - // Methods - loadOrder, - handleCancel, - handlePay, - handleSubmitHandoff, - handleConfirmReceive, - handleSubmitCheckout, - handleAcceptCheckout, - handleCounterCheckout, - handleRejectCheckout, - handleConfirmCheckout, - handleCreateDispute, - handleUploadEvidence, - handleOpenChat, - startPaymentPolling, - stopPaymentPolling, - checkPaymentStatus, - getOrderStep, - } -} diff --git a/frontend/src/composables/order/useOrderSnapshot.ts b/frontend/src/composables/order/useOrderSnapshot.ts deleted file mode 100644 index 0e3a09c..0000000 --- a/frontend/src/composables/order/useOrderSnapshot.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { Order } from '@/api/orders' - -export interface SnapshotResource { - key: string - label: string - quantity: number - unitPrice: number - chargeMode: '赠送' | '收费' -} - -export function readSnapshot(order: Order | null) { - if (!order?.listing_snapshot) return null - try { - return JSON.parse(order.listing_snapshot) as Record - } catch { - return null - } -} - -export function readNumber(value: unknown): number { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : 0 -} - -export function roundQuantity(value: number): number { - return Math.round(value * 10) / 10 -} - -export function roundMoney(value: number): number { - return Math.round(value * 100) / 100 -} - -export function readSnapshotResources(order: Order | null): SnapshotResource[] { - const snapshot = readSnapshot(order) - if (!snapshot?.quantities) return [] - - const quantities = snapshot.quantities as Record[] - return quantities - .map((item) => ({ - key: String(item.key || ''), - label: String(item.label || ''), - quantity: readNumber(item.quantity), - unitPrice: readNumber(item.price), - chargeMode: item.charge_mode === '收费' ? '收费' : '赠送', - })) - .filter((item) => item.key && item.label) -} - -export function isChargedResource(resource: SnapshotResource): boolean { - return resource.chargeMode === '收费' -} - -export function getSnapshotHafCoinM(order: Order | null): number { - const snapshot = readSnapshot(order) - return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000) -} - -export function calculateResourceChargeAmount( - resources: SnapshotResource[], - resourceUsage: Record -): number { - return roundMoney( - resources.reduce((sum, item) => { - if (!isChargedResource(item)) return sum - const used = resourceUsage[item.key] || 0 - return sum + used * item.unitPrice - }, 0) - ) -} - -export function calculateCheckoutTotal( - resourceCharge: number, - consumableAmount: number, - coinConsumed: number, - otherAmount: number -): number { - return roundMoney(resourceCharge + consumableAmount + coinConsumed + otherAmount) -} - -export function calculateCounterTotal(counterForm: { - consumable_amount: number - coin_consumed_m: number - other_amount: number - deposit_deduct_amount: number -}): number { - return roundMoney( - counterForm.consumable_amount + - counterForm.coin_consumed_m + - counterForm.other_amount + - counterForm.deposit_deduct_amount - ) -} - -export function hydrateResourceUsageFromOrder( - order: Order | null, - resources: SnapshotResource[] -): Record { - const usage: Record = {} - - if (!order?.checkout_info) { - return usage - } - - try { - const info = JSON.parse(order.checkout_info) as Record - const consumedResources = info.consumed_resources as Record | undefined - - if (consumedResources) { - resources.forEach((res) => { - if (res.key in consumedResources) { - usage[res.key] = consumedResources[res.key] - } - }) - } - } catch { - // ignore - } - - return usage -} - -export function hydrateCounterFormFromOrder(order: Order | null) { - if (!order?.counter_info) { - return null - } - - try { - const info = JSON.parse(order.counter_info) as Record - return { - consumable_amount: readNumber(info.consumable_amount), - coin_consumed_m: readNumber(info.coin_consumed_m), - other_amount: readNumber(info.other_amount), - deposit_deduct_amount: readNumber(info.deposit_deduct_amount), - reason: String(info.reason || ''), - evidenceText: String(info.evidence || ''), - } - } catch { - return null - } -} - -export function readError(error: unknown, fallback: string): string { - if (error && typeof error === 'object' && 'message' in error) { - return String(error.message) - } - return fallback -} diff --git a/frontend/src/composables/performance/useDebounceThrottle.ts b/frontend/src/composables/performance/useDebounceThrottle.ts deleted file mode 100644 index 7954476..0000000 --- a/frontend/src/composables/performance/useDebounceThrottle.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ref } from 'vue' - -/** - * 防抖 - 延迟执行,多次触发只执行最后一次 - * @param fn 要执行的函数 - * @param delay 延迟时间(毫秒) - */ -export function useDebounce any>(fn: T, delay = 300) { - const timer = ref | null>(null) - - function debouncedFn(...args: Parameters) { - if (timer.value) { - clearTimeout(timer.value) - } - timer.value = setTimeout(() => { - fn(...args) - timer.value = null - }, delay) - } - - function cancel() { - if (timer.value) { - clearTimeout(timer.value) - timer.value = null - } - } - - return { - run: debouncedFn, - cancel, - } -} - -/** - * 节流 - 固定时间间隔执行 - * @param fn 要执行的函数 - * @param interval 时间间隔(毫秒) - */ -export function useThrottle any>(fn: T, interval = 300) { - const lastTime = ref(0) - - function throttledFn(...args: Parameters) { - const now = Date.now() - if (now - lastTime.value >= interval) { - lastTime.value = now - fn(...args) - } - } - - return { - run: throttledFn, - } -} diff --git a/frontend/src/composables/performance/useLazyImage.ts b/frontend/src/composables/performance/useLazyImage.ts deleted file mode 100644 index 52d63db..0000000 --- a/frontend/src/composables/performance/useLazyImage.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { onMounted, onUnmounted, ref } from 'vue' - -/** - * 图片懒加载 composable - * 使用 Intersection Observer API 实现高性能懒加载 - */ -export function useLazyImage() { - const imageRefs = ref>(new Set()) - let observer: IntersectionObserver | null = null - - onMounted(() => { - observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const img = entry.target as HTMLImageElement - const src = img.dataset.src - if (src) { - img.src = src - img.removeAttribute('data-src') - observer?.unobserve(img) - } - } - }) - }, - { - rootMargin: '50px', // 提前50px加载 - threshold: 0.01, - } - ) - - imageRefs.value.forEach((img) => { - observer?.observe(img) - }) - }) - - onUnmounted(() => { - observer?.disconnect() - }) - - function registerImage(el: HTMLImageElement | null) { - if (el && observer) { - imageRefs.value.add(el) - observer.observe(el) - } - } - - return { - registerImage, - } -} diff --git a/frontend/src/composables/performance/useLazyLoad.ts b/frontend/src/composables/performance/useLazyLoad.ts deleted file mode 100644 index 346afbb..0000000 --- a/frontend/src/composables/performance/useLazyLoad.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { defineAsyncComponent, type Component } from 'vue' - -/** - * 组件懒加载包装器 - * 自动添加 loading 和 error 处理 - */ -export function lazyLoadComponent(importFunc: () => Promise) { - return defineAsyncComponent({ - loader: importFunc, - loadingComponent: { - template: '
加载中...
', - }, - errorComponent: { - template: '
加载失败,请刷新重试
', - }, - delay: 200, // 延迟200ms显示loading - timeout: 10000, // 10秒超时 - }) -} - -/** - * 路由级别懒加载 - * 使用 webpack 魔法注释自动分包 - */ -export function lazyLoadRoute(path: string) { - return () => import(/* webpackChunkName: "[request]" */ `@/views/${path}.vue`) -} diff --git a/frontend/src/composables/useAdminPaginatedTable.ts b/frontend/src/composables/useAdminPaginatedTable.ts deleted file mode 100644 index 62481e6..0000000 --- a/frontend/src/composables/useAdminPaginatedTable.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ref, type Ref } from 'vue' - -interface PaginatedResult { - items: T[] - total: number -} - -interface AdminPaginatedTableOptions { - fetchFn: (page: number, pageSize: number) => Promise> - defaultPageSize?: number - immediate?: boolean -} - -interface AdminPaginatedTableResult { - loading: Ref - data: Ref - total: Ref - currentPage: Ref - currentPageSize: Ref - load: () => Promise - handleSizeChange: () => void -} - -export function useAdminPaginatedTable(options: AdminPaginatedTableOptions): AdminPaginatedTableResult { - const loading = ref(false) - const data = ref([]) as Ref - const total = ref(0) - const currentPage = ref(1) - const currentPageSize = ref(options.defaultPageSize || 20) - - async function load() { - loading.value = true - try { - const result = await options.fetchFn(currentPage.value, currentPageSize.value) - data.value = result.items - total.value = result.total - } finally { - loading.value = false - } - } - - function handleSizeChange() { - currentPage.value = 1 - load() - } - - if (options.immediate !== false) { - load() - } - - return { loading, data, total, currentPage, currentPageSize, load, handleSizeChange } -} diff --git a/frontend/src/composables/useAdminTable.ts b/frontend/src/composables/useAdminTable.ts deleted file mode 100644 index 6bc80e3..0000000 --- a/frontend/src/composables/useAdminTable.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ref, type Ref } from 'vue' - -interface AdminQueryOptions { - fetchFn: () => Promise - immediate?: boolean - initialData?: T -} - -interface AdminQueryResult { - loading: Ref - error: Ref - data: Ref - load: () => Promise -} - -export function useAdminQuery(options: AdminQueryOptions): AdminQueryResult { - const loading = ref(false) - const error = ref(null) - const data = ref(options.initialData as T) as Ref - - async function load() { - loading.value = true - error.value = null - try { - data.value = await options.fetchFn() - } catch (e) { - error.value = e instanceof Error ? e.message : '加载失败' - console.error('Failed to load data:', e) - } finally { - loading.value = false - } - } - - if (options.immediate !== false) { - load() - } - - return { loading, error, data, load } -} - -// 保持向后兼容 -export const useAdminTable = useAdminQuery diff --git a/frontend/src/composables/useChatSSE.ts b/frontend/src/composables/useChatSSE.ts deleted file mode 100644 index d93f5af..0000000 --- a/frontend/src/composables/useChatSSE.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { onBeforeUnmount, ref, type Ref } from 'vue' -import { refreshAccessToken } from '@/api/client' -import { getAccessToken, type AuthScope } from '@/utils/authStorage' - -export interface SSEMessage { - id: number - conversation_id: number - sender_type: string - sender_id: number - sender_role: string - sender_name: string - content_type: string - content: string - attachment_urls: string[] - created_at: string -} - -export interface ChatEvent { - type: 'new_message' | 'conversation_updated' - conversation_id: number - message?: SSEMessage -} - -type EventHandler = (event: ChatEvent) => void - -const reconnectDelay = 3000 - -export function useChatSSE(scope: AuthScope, endpoint: string) { - const connected: Ref = ref(false) - let source: EventSource | null = null - let reconnectTimer: ReturnType | null = null - let stopped = false - let refreshing = false - const handlers: EventHandler[] = [] - - function onEvent(handler: EventHandler) { - handlers.push(handler) - } - - function connect() { - if (stopped || source) return - const token = getAccessToken(scope) - if (!token) return - - const url = `${endpoint}?token=${encodeURIComponent(token)}` - source = new EventSource(url) - - source.addEventListener('connected', () => { - connected.value = true - }) - - source.addEventListener('new_message', (e) => { - try { - const data = JSON.parse((e as MessageEvent).data) as ChatEvent - handlers.forEach(h => h(data)) - } catch { /* ignore */ } - }) - - source.addEventListener('conversation_updated', (e) => { - try { - const data = JSON.parse((e as MessageEvent).data) as ChatEvent - handlers.forEach(h => h(data)) - } catch { /* ignore */ } - }) - - source.onerror = async () => { - connected.value = false - source?.close() - source = null - if (stopped || refreshing) return - - refreshing = true - try { - await refreshAccessToken(scope) - if (!stopped) { - reconnectTimer = setTimeout(connect, reconnectDelay) - } - } catch { - closeSource() - } finally { - refreshing = false - } - } - } - - function closeSource() { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - source?.close() - source = null - connected.value = false - } - - function handleAuthStorageChanged(event: Event) { - const detail = (event as CustomEvent<{ scope?: AuthScope }>).detail - if (detail?.scope !== scope || stopped) return - closeSource() - connect() - } - - function disconnect() { - stopped = true - closeSource() - window.removeEventListener('auth-storage-changed', handleAuthStorageChanged) - } - - window.addEventListener('auth-storage-changed', handleAuthStorageChanged) - - onBeforeUnmount(() => { - disconnect() - }) - - connect() - - return { connected, onEvent, disconnect } -} diff --git a/frontend/src/composables/useMoney.ts b/frontend/src/composables/useMoney.ts deleted file mode 100644 index da286a5..0000000 --- a/frontend/src/composables/useMoney.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function useMoney() { - return (value: number | undefined | null) => `¥${Math.round(Number(value || 0))}` -} diff --git a/frontend/src/composables/usePricingCalculator.ts b/frontend/src/composables/usePricingCalculator.ts deleted file mode 100644 index caa3230..0000000 --- a/frontend/src/composables/usePricingCalculator.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { computed, type Ref } from 'vue' - -import type { ChargeMode, ListingPublishOptions, PublishSalePriceConfig, QuantityKey, ScreenshotKey } from '@/api/listingOptions' -import type { PublishForm } from '@/types/publish' -import { - buildDepositBreakdownItems, - calculateConsumablePrice, - calculateDailyLossRatioAdjustment, - calculatePlatformPricing, - calculateRecommendedDeposit, - calculateSellerReferenceRatio, - formatNumber, - hasAcceleratedSaleRatioInput as hasAcceleratedSaleRatioValue, - isQuantityItemDisabledForInsurance, - readFinalSaleRatio, - roundMoney, - roundRatio, -} from '@/utils/pricing' - -export function usePricingCalculator(options: { - publishOptions: Ref - salePriceConfig: Ref - form: PublishForm - quantityValues: Record - quantityModes: Record - screenshotFiles: Record - selectedSkins: Ref -}) { - const serverOptions = computed(() => options.publishOptions.value.server_options) - const faceOptions = computed(() => options.publishOptions.value.face_options) - const rankOptions = computed(() => options.publishOptions.value.rank_options) - const insuranceOptions = computed(() => options.publishOptions.value.insurance_options) - const levelOptions = computed(() => options.publishOptions.value.level_options) - const loginMethodOptions = computed(() => options.publishOptions.value.login_method_options) - const regionOptions = computed(() => options.publishOptions.value.region_options) - const banRecordOptions = computed(() => options.publishOptions.value.ban_record_options) - const banEvidenceOptions = computed(() => options.publishOptions.value.ban_evidence_options) - const skinGroups = computed(() => options.publishOptions.value.skin_groups) - const quantityItems = computed(() => options.publishOptions.value.quantity_items) - const screenshotSlots = computed(() => options.publishOptions.value.screenshot_slots) - const priceConfig = computed(() => options.publishOptions.value.price_config) - const depositRecommendConfig = computed(() => options.publishOptions.value.deposit_recommend_config) - const fireLevelMin = computed(() => options.publishOptions.value.fire_level_min || 38) - const fireLevelPlaceholder = computed(() => `等级低于${fireLevelMin.value}级的号无法发布`) - const coinMAmount = computed(() => Number(options.form.haf_coin_amount || 0)) - const coinWanAmount = computed(() => coinMAmount.value * 100) - const dailyLossMAmount = computed(() => Number(options.form.daily_loss_m || 10)) - const dailyLossRatioAdjustment = computed(() => calculateDailyLossRatioAdjustment(dailyLossMAmount.value)) - const screenshotUrls = computed(() => - screenshotSlots.value.map((item) => options.screenshotFiles?.[item.key]).filter((url): url is string => Boolean(url)), - ) - - function hasAcceleratedSaleRatioInput() { - return hasAcceleratedSaleRatioValue(options.form.accelerated_sale_ratio) - } - - function isQuantityItemDisabled(item: { key: string; label: string }) { - return isQuantityItemDisabledForInsurance(item, options.form.season_insurance) - } - - const calculatedSellerReferenceRatio = computed(() => - calculateSellerReferenceRatio({ - coinMAmount: coinMAmount.value, - form: options.form, - ratioConfig: options.publishOptions.value.ratio_config, - skinGroups: skinGroups.value, - selectedSkins: options.selectedSkins.value, - levelOptions: levelOptions.value, - dailyLossRatioAdjustment: dailyLossRatioAdjustment.value, - }), - ) - const calculatedDefaultSaleRatio = computed(() => calculatedSellerReferenceRatio.value) - const maxAcceleratedSaleRatio = computed(() => - calculatedDefaultSaleRatio.value > 0 ? roundRatio(calculatedDefaultSaleRatio.value + 10) : 0, - ) - const calculatedRatio = computed(() => - readFinalSaleRatio( - calculatedDefaultSaleRatio.value, - options.form.accelerated_sale_ratio, - maxAcceleratedSaleRatio.value, - ), - ) - const calculatedCoinBasePrice = computed(() => { - if (calculatedRatio.value <= 0) return 0 - return roundMoney(coinWanAmount.value / calculatedRatio.value) - }) - const calculatedConsumablePrice = computed(() => - calculateConsumablePrice({ - quantityItems: quantityItems.value, - quantityValues: options.quantityValues, - quantityModes: options.quantityModes, - seasonInsurance: options.form.season_insurance, - }), - ) - const calculatedSellerPrice = computed(() => - calculatedRatio.value > 0 ? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value) : 0, - ) - const calculatedPlatformPricing = computed(() => - calculatePlatformPricing({ - coinMAmount: coinMAmount.value, - coinWanAmount: coinWanAmount.value, - sellerRatio: calculatedRatio.value, - sellerCoinBasePrice: calculatedCoinBasePrice.value, - sellerTotalPrice: calculatedSellerPrice.value, - consumablePrice: calculatedConsumablePrice.value, - salePriceConfig: options.salePriceConfig.value, - }), - ) - const calculatedFinalPrice = computed(() => calculatedPlatformPricing.value.buyerTotalPrice) - const calculatedRatioText = computed(() => (calculatedRatio.value > 0 ? `1:${formatNumber(calculatedRatio.value)}` : '--')) - const calculatedDefaultSaleRatioText = computed(() => - calculatedDefaultSaleRatio.value > 0 ? `1:${formatNumber(calculatedDefaultSaleRatio.value)}` : '--', - ) - const saleRatioRangeText = computed(() => { - if (calculatedDefaultSaleRatio.value <= 0) return '完成基础信息后自动计算参考比例' - return `可设置 1:${formatNumber(calculatedDefaultSaleRatio.value)} ~ 1:${formatNumber(maxAcceleratedSaleRatio.value)}` - }) - const acceleratedSaleRatioPlaceholder = computed(() => { - if (calculatedDefaultSaleRatio.value <= 0) return '填写资料后自动生成可设置范围' - return `默认 ${formatNumber(calculatedDefaultSaleRatio.value)},最高 ${formatNumber(maxAcceleratedSaleRatio.value)}` - }) - const recommendedDepositAmount = computed(() => - calculateRecommendedDeposit({ - depositRecommendConfig: depositRecommendConfig.value, - skinGroups: skinGroups.value, - selectedSkins: options.selectedSkins.value, - }), - ) - const depositBreakdownItems = computed(() => - buildDepositBreakdownItems({ - depositRecommendConfig: depositRecommendConfig.value, - skinGroups: skinGroups.value, - selectedSkins: options.selectedSkins.value, - }), - ) - const platformRuleLabel = computed(() => { - const labels: Record = { - fixed_markup: '固定加价', - ratio_subtract: '比例修正', - none: '无加价', - } - return labels[calculatedPlatformPricing.value.ruleType] || calculatedPlatformPricing.value.ruleType - }) - const publishTitle = computed(() => { - const parts = [ - options.form.server_region, - options.form.rank_level, - coinMAmount.value ? `${coinMAmount.value}M哈夫币` : '', - ].filter(Boolean) - return parts.length ? parts.join(' ') : '待完善账号信息' - }) - - return { - serverOptions, - faceOptions, - rankOptions, - insuranceOptions, - levelOptions, - loginMethodOptions, - regionOptions, - banRecordOptions, - banEvidenceOptions, - skinGroups, - quantityItems, - screenshotSlots, - priceConfig, - depositRecommendConfig, - fireLevelMin, - fireLevelPlaceholder, - coinMAmount, - coinWanAmount, - dailyLossMAmount, - dailyLossRatioAdjustment, - screenshotUrls, - calculatedSellerReferenceRatio, - calculatedDefaultSaleRatio, - maxAcceleratedSaleRatio, - calculatedRatio, - calculatedCoinBasePrice, - calculatedConsumablePrice, - calculatedSellerPrice, - calculatedPlatformPricing, - calculatedFinalPrice, - calculatedRatioText, - calculatedDefaultSaleRatioText, - saleRatioRangeText, - acceleratedSaleRatioPlaceholder, - recommendedDepositAmount, - depositBreakdownItems, - platformRuleLabel, - publishTitle, - hasAcceleratedSaleRatioInput, - isQuantityItemDisabled, - } -} diff --git a/frontend/src/composables/usePublishDraft.ts b/frontend/src/composables/usePublishDraft.ts deleted file mode 100644 index a71a540..0000000 --- a/frontend/src/composables/usePublishDraft.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { ChargeMode, QuantityKey, ScreenshotKey } from '@/api/listingOptions' -import type { PublishDraft, PublishForm } from '@/types/publish' - -export function defaultPublishForm(): PublishForm { - return { - server_region: '', - face_owner: '', - haf_coin_amount: '', - rank_level: '', - secret_kd: '', - fire_level: '', - daily_loss_m: 10, - accelerated_sale_ratio: '', - season_insurance: '', - stamina_level: '', - load_level: '', - login_method: '', - online_start: '', - online_end: '', - ban_record: '', - common_regions: [], - deposit_amount: '', - remark: '', - } -} - -export function buildPublishDraft(options: { - form: PublishForm - quantityValues: Record - quantityModes: Record - screenshotFiles: Record - selectedSkins: string[] -}): PublishDraft { - return { - form: { - ...options.form, - common_regions: [...options.form.common_regions], - }, - quantityValues: { ...options.quantityValues }, - quantityModes: { ...options.quantityModes }, - screenshotFiles: { ...options.screenshotFiles }, - selectedSkins: [...options.selectedSkins], - } -} - -export function readPublishDraft(draftKey: string) { - const raw = localStorage.getItem(draftKey) - if (!raw) return null - try { - const draft = JSON.parse(raw) as Partial - return { - form: normalizeDraftForm(draft.form), - quantityValues: normalizeNumberRecord(draft.quantityValues), - quantityModes: normalizeQuantityModes(draft.quantityModes), - screenshotFiles: normalizeStringRecord(draft.screenshotFiles), - selectedSkins: Array.isArray(draft.selectedSkins) - ? draft.selectedSkins.filter((skin): skin is string => typeof skin === 'string') - : [], - } - } catch { - localStorage.removeItem(draftKey) - return null - } -} - -export function writePublishDraft(draftKey: string, draft: PublishDraft) { - const nextValue = JSON.stringify(draft) - if (localStorage.getItem(draftKey) === nextValue) return - localStorage.setItem(draftKey, nextValue) -} - -export function removePublishDraft(draftKey: string) { - localStorage.removeItem(draftKey) -} - -export function clearRecord(record: Record) { - for (const key of Object.keys(record)) delete record[key] -} - -function normalizeDraftForm(value: unknown): PublishForm { - const next = defaultPublishForm() - if (!isRecord(value)) return next - for (const key of Object.keys(next) as Array) { - if (key === 'common_regions') continue - const draftValue = value[key] - if (draftValue !== undefined) next[key] = draftValue as never - } - next.common_regions = Array.isArray(value.common_regions) - ? value.common_regions.filter((region): region is string => typeof region === 'string') - : [] - return next -} - -function normalizeNumberRecord(value: unknown) { - const record: Record = {} - if (!isRecord(value)) return record - for (const [key, item] of Object.entries(value)) { - const parsed = Number(item) - if (Number.isFinite(parsed)) record[key] = parsed - } - return record -} - -function normalizeStringRecord(value: unknown) { - const record: Record = {} - if (!isRecord(value)) return record - for (const [key, item] of Object.entries(value)) { - if (typeof item === 'string') record[key] = item - } - return record -} - -function normalizeQuantityModes(value: unknown) { - const record: Record = {} - if (!isRecord(value)) return record - for (const [key, item] of Object.entries(value)) { - if (item === '赠送' || item === '收费') record[key] = item - } - return record -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} diff --git a/frontend/src/composables/usePublishForm.ts b/frontend/src/composables/usePublishForm.ts deleted file mode 100644 index f18690f..0000000 --- a/frontend/src/composables/usePublishForm.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' -import { useRouter } from 'vue-router' - -import { fetchFileBlobByURL, uploadFile } from '@/api/files' -import { - emptyListingPublishOptions, - emptyListingSalePriceConfig, - fetchListingPublishOptions, - fetchListingSalePriceConfig, - type ChargeMode, - type ListingPublishOptions, - type PublishSalePriceConfig, - type ScreenshotKey, -} from '@/api/listingOptions' -import { createListing } from '@/api/listings' -import { usePricingCalculator } from '@/composables/usePricingCalculator' -import { - buildPublishDraft, - clearRecord, - defaultPublishForm, - readPublishDraft, - removePublishDraft, - writePublishDraft, -} from '@/composables/usePublishDraft' -import type { PublishForm } from '@/types/publish' -import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing' - -const draftSaveDelay = 400 - -interface UsePublishFormOptions { - draftKey: string - submitSuccessPath: string - persistBeforeUnload?: boolean - confirmReset?: () => Promise - notifySuccess: (message: string) => void - notifyWarning: (message: string) => void - notifyError: (message: string) => void -} - -export function usePublishForm(options: UsePublishFormOptions) { - const router = useRouter() - const loading = ref(false) - const uploading = ref(false) - const suppressDraftSave = ref(false) - const draftReady = ref(false) - const publishOptions = ref(emptyListingPublishOptions) - const salePriceConfig = ref(emptyListingSalePriceConfig) - const fileInput = ref(null) - const activeUploadKey = ref('coin') - const form = reactive(defaultPublishForm()) - const quantityValues = reactive>({}) - const quantityModes = reactive>({}) - const screenshotFiles = reactive>({}) - const screenshotPreviews = reactive>({}) - const selectedSkins = ref([]) - let draftSaveTimer: number | undefined - - const pricing = usePricingCalculator({ - form, - publishOptions, - salePriceConfig, - quantityValues, - quantityModes, - screenshotFiles, - selectedSkins, - }) - const uploadedScreenshotCount = computed(() => pricing.screenshotUrls.value.length) - const requiredScreenshotCount = ref(0) - - onMounted(() => { - restoreDraft() - draftReady.value = true - if (options.persistBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload) - loadPublishOptions() - }) - - onBeforeUnmount(() => { - saveDraft({ force: true }) - if (options.persistBeforeUnload) window.removeEventListener('beforeunload', handleBeforeUnload) - revokeAllScreenshotPreviews() - }) - - watch( - [form, quantityValues, quantityModes, screenshotFiles, selectedSkins], - () => { - saveDraft() - }, - { deep: true }, - ) - - watch( - pricing.recommendedDepositAmount, - () => { - syncRecommendedDeposit() - }, - { immediate: true }, - ) - - watch( - [() => form.season_insurance, pricing.quantityItems], - () => { - clearForbiddenQuantityItems() - }, - { deep: true }, - ) - - watch( - [pricing.screenshotSlots, () => form.ban_record], - () => { - requiredScreenshotCount.value = pricing.screenshotSlots.value.filter((item) => isScreenshotRequired(item)).length - }, - { immediate: true, deep: true }, - ) - - async function loadPublishOptions() { - try { - const [nextPublishOptions, nextSalePriceConfig] = await Promise.all([ - fetchListingPublishOptions(), - fetchListingSalePriceConfig(), - ]) - publishOptions.value = nextPublishOptions - salePriceConfig.value = nextSalePriceConfig - - // 默认数字都是0 - for (const item of nextPublishOptions.quantity_items) { - if (quantityValues[item.key] === undefined) { - quantityValues[item.key] = 0 - } - } - } catch { - publishOptions.value = emptyListingPublishOptions - salePriceConfig.value = emptyListingSalePriceConfig - } - } - - function saveDraft(saveOptions: { force?: boolean } = {}) { - if (suppressDraftSave.value) { - clearDraftSaveTimer() - return - } - if (!saveOptions.force && !draftReady.value) return - if (!saveOptions.force) { - scheduleDraftSave() - return - } - clearDraftSaveTimer() - writeDraft() - } - - function scheduleDraftSave() { - clearDraftSaveTimer() - draftSaveTimer = window.setTimeout(() => { - draftSaveTimer = undefined - writeDraft() - }, draftSaveDelay) - } - - function clearDraftSaveTimer() { - if (draftSaveTimer === undefined) return - window.clearTimeout(draftSaveTimer) - draftSaveTimer = undefined - } - - function writeDraft() { - writePublishDraft( - options.draftKey, - buildPublishDraft({ - form, - quantityValues, - quantityModes, - screenshotFiles, - selectedSkins: selectedSkins.value, - }), - ) - } - - function handleSaveDraft() { - saveDraft({ force: true }) - options.notifySuccess('草稿已保存') - } - - function handleBeforeUnload() { - saveDraft({ force: true }) - } - - function restoreDraft() { - const draft = readPublishDraft(options.draftKey) - if (!draft) return - Object.assign(form, draft.form) - clearRecord(quantityValues) - clearRecord(quantityModes) - clearRecord(screenshotFiles) - Object.assign(quantityValues, draft.quantityValues) - Object.assign(quantityModes, draft.quantityModes) - Object.assign(screenshotFiles, draft.screenshotFiles) - selectedSkins.value = draft.selectedSkins - hydrateScreenshotPreviews() - } - - function resetDraftState() { - Object.assign(form, defaultPublishForm()) - clearRecord(quantityValues) - clearRecord(quantityModes) - clearRecord(screenshotFiles) - revokeAllScreenshotPreviews() - selectedSkins.value = [] - activeUploadKey.value = 'coin' - - // Also re-initialize quantityValues to 0 - for (const item of publishOptions.value.quantity_items) { - quantityValues[item.key] = 0 - } - } - - async function handleResetDraft() { - try { - await options.confirmReset?.() - } catch { - return - } - suppressDraftSave.value = true - clearDraftSaveTimer() - resetDraftState() - removePublishDraft(options.draftKey) - options.notifySuccess('已重置') - window.setTimeout(() => { - suppressDraftSave.value = false - }) - } - - function toggleSkin(skin: string) { - selectedSkins.value = selectedSkins.value.includes(skin) - ? selectedSkins.value.filter((item) => item !== skin) - : [...selectedSkins.value, skin] - } - - function toggleRegion(region: string) { - form.common_regions = form.common_regions.includes(region) - ? form.common_regions.filter((item) => item !== region) - : [...form.common_regions, region] - } - - function triggerUpload(key: ScreenshotKey) { - activeUploadKey.value = key - fileInput.value?.click() - } - - async function handleScreenshotUpload(event: Event) { - const input = event.target as HTMLInputElement - const file = input.files?.[0] - if (!file) return - const key = activeUploadKey.value - const previewURL = URL.createObjectURL(file) - setScreenshotPreview(key, previewURL) - uploading.value = true - try { - const uploaded = await uploadFile(file, 'listing') - screenshotFiles[key] = uploaded.url - options.notifySuccess('截图已上传') - } catch (error) { - revokeScreenshotPreview(key) - options.notifyError(readError(error, '截图上传失败')) - } finally { - uploading.value = false - input.value = '' - } - } - - function removeScreenshot(key: ScreenshotKey) { - screenshotFiles[key] = '' - revokeScreenshotPreview(key) - } - - function getScreenshotPreviewURL(key: ScreenshotKey) { - return screenshotPreviews[key] || screenshotFiles[key] || '' - } - - function setScreenshotPreview(key: ScreenshotKey, previewURL: string) { - revokeScreenshotPreview(key) - screenshotPreviews[key] = previewURL - } - - function revokeScreenshotPreview(key: ScreenshotKey) { - const previewURL = screenshotPreviews[key] - if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL) - delete screenshotPreviews[key] - } - - function revokeAllScreenshotPreviews() { - for (const key of Object.keys(screenshotPreviews)) revokeScreenshotPreview(key) - } - - async function hydrateScreenshotPreviews() { - for (const [key, fileURL] of Object.entries(screenshotFiles)) { - if (!fileURL || screenshotPreviews[key] || !fileURL.startsWith('/api/files/object')) continue - try { - const blob = await fetchFileBlobByURL(fileURL) - setScreenshotPreview(key, URL.createObjectURL(blob)) - } catch { - // 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。 - } - } - } - - function handleFireLevelInput(value: string | number | undefined) { - if (value === '' || value === undefined) { - form.fire_level = '' - return - } - const level = Number(value) - form.fire_level = Number.isFinite(level) ? Math.trunc(level) : '' - } - - function handleAcceleratedSaleRatioInput(value: string | number | undefined) { - if (value === '' || value === undefined) { - form.accelerated_sale_ratio = '' - return - } - const ratio = Number(value) - form.accelerated_sale_ratio = Number.isFinite(ratio) ? ratio : '' - } - - function syncRecommendedDeposit() { - const recommended = pricing.recommendedDepositAmount.value - if (recommended <= 0) return - const current = Number(form.deposit_amount) - if (form.deposit_amount === '' || !Number.isFinite(current) || current < recommended) { - form.deposit_amount = recommended - } - } - - function useRecommendedDeposit() { - if (pricing.recommendedDepositAmount.value > 0) { - form.deposit_amount = pricing.recommendedDepositAmount.value - } - } - - function clearForbiddenQuantityItems() { - for (const item of pricing.quantityItems.value) { - if (!pricing.isQuantityItemDisabled(item)) continue - quantityValues[item.key] = 0 - quantityModes[item.key] = '赠送' - } - } - - function setQuantityMode(item: { key: string; label: string }, mode: ChargeMode) { - if (pricing.isQuantityItemDisabled(item)) return - quantityModes[item.key] = mode - } - - function clampAcceleratedSaleRatioInput() { - if (!pricing.hasAcceleratedSaleRatioInput() || pricing.calculatedDefaultSaleRatio.value <= 0) return - const ratio = Number(form.accelerated_sale_ratio) - if (!Number.isFinite(ratio)) { - form.accelerated_sale_ratio = '' - return - } - form.accelerated_sale_ratio = roundRatio( - Math.min(Math.max(ratio, pricing.calculatedDefaultSaleRatio.value), pricing.maxAcceleratedSaleRatio.value), - ) - } - - function useReferenceSaleRatio() { - form.accelerated_sale_ratio = '' - } - - function useMaxAcceleratedSaleRatio() { - if (pricing.maxAcceleratedSaleRatio.value <= 0) return - form.accelerated_sale_ratio = pricing.maxAcceleratedSaleRatio.value - } - - async function handleSubmit() { - const error = validateForm() - if (error) { - options.notifyWarning(error) - return - } - loading.value = true - try { - const listing = await createListing({ - title: pricing.publishTitle.value, - description: form.remark, - server_region: form.server_region, - login_platform: form.login_method, - rank_level: form.rank_level, - haf_coin_amount: pricing.coinMAmount.value * 1000000, - asset_summary: buildAssetSummary(), - screenshot_urls: pricing.screenshotUrls.value, - price: pricing.calculatedFinalPrice.value, - deposit_amount: Number(form.deposit_amount), - }) - removePublishDraft(options.draftKey) - suppressDraftSave.value = true - clearDraftSaveTimer() - options.notifySuccess( - listing.status === 'published' && listing.review_status === 'approved' - ? '发布成功,已上架' - : '发布成功,等待后台审核', - ) - await router.push(options.submitSuccessPath) - } catch (error) { - options.notifyError(readError(error, '发布失败,请确认已登录并完成实名认证')) - } finally { - loading.value = false - } - } - - function validateForm() { - if (!form.server_region) return '请选择区服' - if (pricing.coinMAmount.value <= 0) return '请填写哈夫币/M' - if (!form.rank_level) return '请选择段位' - if (!form.fire_level) return '请填写烽火等级' - if (Number(form.fire_level) < pricing.fireLevelMin.value) return `烽火等级低于${pricing.fireLevelMin.value}级的号无法发布` - if (!form.season_insurance) return '请选择赛季保险' - if (!form.stamina_level) return '请选择体力等级' - if (!form.load_level) return '请选择负重等级' - if (!dailyLossOptions.includes(pricing.dailyLossMAmount.value)) return '请选择每日损耗' - if (pricing.hasAcceleratedSaleRatioInput()) { - const ratio = Number(form.accelerated_sale_ratio) - if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确' - if (pricing.calculatedDefaultSaleRatio.value > 0 && ratio < pricing.calculatedDefaultSaleRatio.value) { - return `加速出售比例不能低于默认比例 1:${formatNumber(pricing.calculatedDefaultSaleRatio.value)}` - } - if (pricing.maxAcceleratedSaleRatio.value > 0 && ratio > pricing.maxAcceleratedSaleRatio.value) { - return `加速出售比例不能超过 1:${formatNumber(pricing.maxAcceleratedSaleRatio.value)}` - } - } - if (pricing.banRecordOptions.value.length && !form.ban_record) return '请选择封禁记录' - if (form.deposit_amount === '' || Number(form.deposit_amount) < 0) return '请填写押金' - if (!Number.isFinite(Number(form.deposit_amount))) return '押金格式不正确' - if (pricing.recommendedDepositAmount.value > 0 && Number(form.deposit_amount) < pricing.recommendedDepositAmount.value) { - return `押金不能低于智能推荐 ¥${pricing.recommendedDepositAmount.value}` - } - if (pricing.calculatedConsumablePrice.value > 0 && Number(form.deposit_amount) <= pricing.calculatedConsumablePrice.value) { - return `押金必须大于额外消耗品总价值 ¥${pricing.calculatedConsumablePrice.value}` - } - if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布' - if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容' - for (const item of pricing.screenshotSlots.value) { - if (isScreenshotRequired(item) && !screenshotFiles[item.key]) return `请上传${item.label}` - } - for (const item of pricing.quantityItems.value) { - if (!pricing.isQuantityItemDisabled(item)) { - const val = quantityValues[item.key] - if (val === undefined || val === null || (val as unknown) === '') { - return `请填写${item.label}的数量` - } - if (Number(val) < 0) { - return `${item.label}的数量不能为负数` - } - } - } - for (const item of pricing.quantityItems.value) { - if (pricing.isQuantityItemDisabled(item) && Number(quantityValues[item.key] || 0) > 0) { - return '赛季保险选择 3*3 时不能填写 9格体验卡' - } - } - return '' - } - - function isScreenshotRequired(item: { key: string; required: boolean }) { - return item.required || (item.key === 'tencentSecurity' && shouldRequireBanEvidence()) - } - - function shouldRequireBanEvidence() { - return pricing.banEvidenceOptions.value.includes(form.ban_record) - } - - function buildAssetSummary() { - return { - face_owner: form.face_owner, - secret_kd: form.secret_kd, - fire_level: Number(form.fire_level), - daily_loss_m: pricing.dailyLossMAmount.value, - publish_ratio: pricing.calculatedPlatformPricing.value.buyerRatio, - price_breakdown: { - seller_reference_ratio: pricing.calculatedSellerReferenceRatio.value, - seller_ratio: pricing.calculatedRatio.value, - seller_coin_base_price: pricing.calculatedCoinBasePrice.value, - seller_total_price: pricing.calculatedSellerPrice.value, - consumable_price: pricing.calculatedConsumablePrice.value, - daily_loss_ratio_adjustment: pricing.dailyLossRatioAdjustment.value, - accelerated_sale_ratio: pricing.hasAcceleratedSaleRatioInput() - ? Number(form.accelerated_sale_ratio) - : pricing.calculatedDefaultSaleRatio.value, - buyer_coin_base_price: pricing.calculatedPlatformPricing.value.buyerCoinBasePrice, - buyer_total_price: pricing.calculatedFinalPrice.value, - buyer_ratio: pricing.calculatedPlatformPricing.value.buyerRatio, - platform_markup_amount: pricing.calculatedPlatformPricing.value.platformMarkupAmount, - platform_rule_type: pricing.calculatedPlatformPricing.value.ruleType, - }, - season_insurance: form.season_insurance, - stamina_level: form.stamina_level, - load_level: form.load_level, - resources: pricing.quantityItems.value.map((item) => ({ - key: item.key, - label: item.label, - price: item.price, - quantity: pricing.isQuantityItemDisabled(item) ? 0 : Number(quantityValues[item.key] || 0), - mode: pricing.isQuantityItemDisabled(item) ? '赠送' : quantityModes[item.key] || '收费', - })), - skin_groups: pricing.skinGroups.value.reduce>((groups, group) => { - groups[group.key] = group.options.filter((skin) => selectedSkins.value.includes(skin)) - return groups - }, {}), - online_time: { - start: form.online_start, - end: form.online_end, - }, - ban_record: form.ban_record, - common_regions: form.common_regions, - remark: form.remark, - } - } - - return { - ...pricing, - dailyLossOptions, - commonOnlineTimes, - formatNumber, - router, - loading, - uploading, - fileInput, - activeUploadKey, - form, - quantityValues, - quantityModes, - screenshotFiles, - screenshotPreviews, - selectedSkins, - uploadedScreenshotCount, - requiredScreenshotCount, - loadPublishOptions, - saveDraft, - handleSaveDraft, - resetDraftState, - handleResetDraft, - toggleSkin, - toggleRegion, - triggerUpload, - handleScreenshotUpload, - removeScreenshot, - getScreenshotPreviewURL, - handleFireLevelInput, - handleAcceleratedSaleRatioInput, - syncRecommendedDeposit, - useRecommendedDeposit, - clearForbiddenQuantityItems, - setQuantityMode, - clampAcceleratedSaleRatioInput, - useReferenceSaleRatio, - useMaxAcceleratedSaleRatio, - handleSubmit, - validateForm, - isScreenshotRequired, - shouldRequireBanEvidence, - buildAssetSummary, - } -} - -function readError(error: unknown, fallback: string) { - if (typeof error === 'object' && error && 'response' in error) { - const response = (error as { response?: { data?: { message?: string } } }).response - return response?.data?.message || fallback - } - return fallback -} diff --git a/frontend/src/composables/useSmsCountdown.ts b/frontend/src/composables/useSmsCountdown.ts deleted file mode 100644 index 0aaed28..0000000 --- a/frontend/src/composables/useSmsCountdown.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { onUnmounted, ref } from "vue"; -import { showToast } from "vant"; -import { sendSmsCode } from "@/api/auth"; - -export function useSmsCountdown() { - const countDown = ref(0); - const sending = ref(false); - let timer: ReturnType | null = null; - - function startCountDown() { - countDown.value = 60; - timer = setInterval(() => { - countDown.value--; - if (countDown.value <= 0) { - clearInterval(timer!); - timer = null; - } - }, 1000); - } - - onUnmounted(() => { - if (timer) { - clearInterval(timer); - timer = null; - } - }); - - async function handleSendCode(phone: string) { - if (!phone.trim()) { - showToast({ message: "请输入手机号", icon: "warning-o" }); - return false; - } - - sending.value = true; - try { - await sendSmsCode(phone); - showToast({ - message: "验证码已发送,请注意查收", - icon: "passed", - }); - startCountDown(); - return true; - } catch (error) { - showToast({ - message: readError(error, "验证码发送失败,请稍后重试"), - icon: "cross", - }); - return false; - } finally { - sending.value = false; - } - } - - function readError(error: unknown, fallback: string) { - if (typeof error === "object" && error && "response" in error) { - const response = (error as { response?: { data?: { message?: string } } }) - .response; - return response?.data?.message || fallback; - } - return fallback; - } - - return { - countDown, - sending, - handleSendCode, - readError, - }; -} diff --git a/frontend/src/views/account/ChatView.vue b/frontend/src/views/account/ChatView.vue deleted file mode 100644 index 0e1f4d8..0000000 --- a/frontend/src/views/account/ChatView.vue +++ /dev/null @@ -1,540 +0,0 @@ - - - - - - diff --git a/frontend/src/views/account/LoginView.vue b/frontend/src/views/account/LoginView.vue deleted file mode 100644 index 8324578..0000000 --- a/frontend/src/views/account/LoginView.vue +++ /dev/null @@ -1,409 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/MessagesView.vue b/frontend/src/views/account/MessagesView.vue deleted file mode 100644 index 3f8cb44..0000000 --- a/frontend/src/views/account/MessagesView.vue +++ /dev/null @@ -1,290 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/NotificationsView.vue b/frontend/src/views/account/NotificationsView.vue deleted file mode 100644 index 7bc4f38..0000000 --- a/frontend/src/views/account/NotificationsView.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - diff --git a/frontend/src/views/account/OrderCreateView.vue b/frontend/src/views/account/OrderCreateView.vue deleted file mode 100644 index 2850aed..0000000 --- a/frontend/src/views/account/OrderCreateView.vue +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/frontend/src/views/account/OrderDetailView.vue b/frontend/src/views/account/OrderDetailView.vue deleted file mode 100644 index d9b86bb..0000000 --- a/frontend/src/views/account/OrderDetailView.vue +++ /dev/null @@ -1,1507 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/OrdersView.vue b/frontend/src/views/account/OrdersView.vue deleted file mode 100644 index dbe7339..0000000 --- a/frontend/src/views/account/OrdersView.vue +++ /dev/null @@ -1,541 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/ProfileView.vue b/frontend/src/views/account/ProfileView.vue deleted file mode 100644 index 038e403..0000000 --- a/frontend/src/views/account/ProfileView.vue +++ /dev/null @@ -1,714 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/RealnameView.vue b/frontend/src/views/account/RealnameView.vue deleted file mode 100644 index b199d1a..0000000 --- a/frontend/src/views/account/RealnameView.vue +++ /dev/null @@ -1,584 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/WalletView.vue b/frontend/src/views/account/WalletView.vue deleted file mode 100644 index ef3421b..0000000 --- a/frontend/src/views/account/WalletView.vue +++ /dev/null @@ -1,691 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/AdminAuditLogsView.vue b/frontend/src/views/admin/AdminAuditLogsView.vue deleted file mode 100644 index 0749ef9..0000000 --- a/frontend/src/views/admin/AdminAuditLogsView.vue +++ /dev/null @@ -1,170 +0,0 @@ - - - diff --git a/frontend/src/views/admin/AdminChatsView.vue b/frontend/src/views/admin/AdminChatsView.vue deleted file mode 100644 index ba760a4..0000000 --- a/frontend/src/views/admin/AdminChatsView.vue +++ /dev/null @@ -1,751 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/AdminDashboardView.vue b/frontend/src/views/admin/AdminDashboardView.vue deleted file mode 100644 index a5cba48..0000000 --- a/frontend/src/views/admin/AdminDashboardView.vue +++ /dev/null @@ -1,494 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/AdminDisputesView.vue b/frontend/src/views/admin/AdminDisputesView.vue deleted file mode 100644 index f8b8dc2..0000000 --- a/frontend/src/views/admin/AdminDisputesView.vue +++ /dev/null @@ -1,192 +0,0 @@ - - - diff --git a/frontend/src/views/admin/AdminListingDetailView.vue b/frontend/src/views/admin/AdminListingDetailView.vue deleted file mode 100644 index 0145a47..0000000 --- a/frontend/src/views/admin/AdminListingDetailView.vue +++ /dev/null @@ -1,178 +0,0 @@ - - - diff --git a/frontend/src/views/admin/AdminListingReviewView.vue b/frontend/src/views/admin/AdminListingReviewView.vue deleted file mode 100644 index a2f8096..0000000 --- a/frontend/src/views/admin/AdminListingReviewView.vue +++ /dev/null @@ -1,963 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/AdminListingsView.vue b/frontend/src/views/admin/AdminListingsView.vue deleted file mode 100644 index 920fb68..0000000 --- a/frontend/src/views/admin/AdminListingsView.vue +++ /dev/null @@ -1,468 +0,0 @@ - - - diff --git a/frontend/src/views/admin/AdminLoginView.vue b/frontend/src/views/admin/AdminLoginView.vue deleted file mode 100644 index 37cc019..0000000 --- a/frontend/src/views/admin/AdminLoginView.vue +++ /dev/null @@ -1,434 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/AdminMgrUsersView.vue b/frontend/src/views/admin/AdminMgrUsersView.vue deleted file mode 100644 index 234c24b..0000000 --- a/frontend/src/views/admin/AdminMgrUsersView.vue +++ /dev/null @@ -1,205 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/AdminOrderDetailView.vue b/frontend/src/views/admin/AdminOrderDetailView.vue deleted file mode 100644 index 054ac08..0000000 --- a/frontend/src/views/admin/AdminOrderDetailView.vue +++ /dev/null @@ -1,202 +0,0 @@ - - - diff --git a/frontend/src/views/admin/AdminOrdersView.vue b/frontend/src/views/admin/AdminOrdersView.vue deleted file mode 100644 index e3834f0..0000000 --- a/frontend/src/views/admin/AdminOrdersView.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - diff --git a/frontend/src/views/admin/AdminRolesView.vue b/frontend/src/views/admin/AdminRolesView.vue deleted file mode 100644 index 4d67323..0000000 --- a/frontend/src/views/admin/AdminRolesView.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/AdminSystemConfigsView.vue b/frontend/src/views/admin/AdminSystemConfigsView.vue deleted file mode 100644 index 7936428..0000000 --- a/frontend/src/views/admin/AdminSystemConfigsView.vue +++ /dev/null @@ -1,406 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/AdminUsersView.vue b/frontend/src/views/admin/AdminUsersView.vue deleted file mode 100644 index e69a41e..0000000 --- a/frontend/src/views/admin/AdminUsersView.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - diff --git a/frontend/src/views/admin/AdminWalletLedgerView.vue b/frontend/src/views/admin/AdminWalletLedgerView.vue deleted file mode 100644 index ee737c4..0000000 --- a/frontend/src/views/admin/AdminWalletLedgerView.vue +++ /dev/null @@ -1,164 +0,0 @@ - - - diff --git a/frontend/src/views/admin/components/AdminUserDialog.vue b/frontend/src/views/admin/components/AdminUserDialog.vue deleted file mode 100644 index 07cddd6..0000000 --- a/frontend/src/views/admin/components/AdminUserDialog.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/AssignPermissionsDialog.vue b/frontend/src/views/admin/components/AssignPermissionsDialog.vue deleted file mode 100644 index 400b3d7..0000000 --- a/frontend/src/views/admin/components/AssignPermissionsDialog.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/AssignRolesDialog.vue b/frontend/src/views/admin/components/AssignRolesDialog.vue deleted file mode 100644 index aaaa5d8..0000000 --- a/frontend/src/views/admin/components/AssignRolesDialog.vue +++ /dev/null @@ -1,115 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/AutoWelcomeConfig.vue b/frontend/src/views/admin/components/AutoWelcomeConfig.vue deleted file mode 100644 index c10a6d9..0000000 --- a/frontend/src/views/admin/components/AutoWelcomeConfig.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/GeneralConfigDialog.vue b/frontend/src/views/admin/components/GeneralConfigDialog.vue deleted file mode 100644 index d3fc64c..0000000 --- a/frontend/src/views/admin/components/GeneralConfigDialog.vue +++ /dev/null @@ -1,134 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/HomeAnnouncementsDialog.vue b/frontend/src/views/admin/components/HomeAnnouncementsDialog.vue deleted file mode 100644 index 4781a6e..0000000 --- a/frontend/src/views/admin/components/HomeAnnouncementsDialog.vue +++ /dev/null @@ -1,153 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/HomeBannersDialog.vue b/frontend/src/views/admin/components/HomeBannersDialog.vue deleted file mode 100644 index f324912..0000000 --- a/frontend/src/views/admin/components/HomeBannersDialog.vue +++ /dev/null @@ -1,286 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/PublishOptionsDialog.vue b/frontend/src/views/admin/components/PublishOptionsDialog.vue deleted file mode 100644 index c05bdbb..0000000 --- a/frontend/src/views/admin/components/PublishOptionsDialog.vue +++ /dev/null @@ -1,574 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/QuickReplyDialog.vue b/frontend/src/views/admin/components/QuickReplyDialog.vue deleted file mode 100644 index 3ca807c..0000000 --- a/frontend/src/views/admin/components/QuickReplyDialog.vue +++ /dev/null @@ -1,281 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/RoleDialog.vue b/frontend/src/views/admin/components/RoleDialog.vue deleted file mode 100644 index c5cad91..0000000 --- a/frontend/src/views/admin/components/RoleDialog.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/SalePriceDialog.vue b/frontend/src/views/admin/components/SalePriceDialog.vue deleted file mode 100644 index be63c05..0000000 --- a/frontend/src/views/admin/components/SalePriceDialog.vue +++ /dev/null @@ -1,216 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/components/TransferDialog.vue b/frontend/src/views/admin/components/TransferDialog.vue deleted file mode 100644 index 5afd4ea..0000000 --- a/frontend/src/views/admin/components/TransferDialog.vue +++ /dev/null @@ -1,195 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileChatView.vue b/frontend/src/views/mobile/MobileChatView.vue deleted file mode 100644 index 2543676..0000000 --- a/frontend/src/views/mobile/MobileChatView.vue +++ /dev/null @@ -1,517 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileHomeFilterSheet.vue b/frontend/src/views/mobile/MobileHomeFilterSheet.vue deleted file mode 100644 index 2078433..0000000 --- a/frontend/src/views/mobile/MobileHomeFilterSheet.vue +++ /dev/null @@ -1,448 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileHomeView.css b/frontend/src/views/mobile/MobileHomeView.css deleted file mode 100644 index 17cc9bd..0000000 --- a/frontend/src/views/mobile/MobileHomeView.css +++ /dev/null @@ -1,587 +0,0 @@ -.mobile-shell { - min-height: 100vh; - background: #f5f7fa; - color: #17233d; - padding-bottom: calc(58px + env(safe-area-inset-bottom)); -} - -/* ========== 顶部信息区 ========== */ -.mobile-hero { - padding: 14px 14px 8px; - background: #f5f7fa; -} - -.mobile-topbar, -.mobile-brand { - display: flex; - align-items: center; -} - -.mobile-topbar { - gap: 10px; - justify-content: space-between; - color: #17233d; -} - -.mobile-brand { - flex: 0 0 auto; - gap: 10px; -} - -.mobile-logo { - display: grid; - width: 36px; - height: 36px; - place-items: center; - border-radius: 12px; - background: #ffffff; - color: #ff6a00; - box-shadow: 0 6px 16px rgba(23, 35, 61, 0.08); - font-weight: 900; -} - -.mobile-brand strong, -.mobile-brand small { - display: block; -} - -.mobile-brand small { - margin-top: 2px; - color: #7b8798; - font-size: 11px; -} - -.mobile-brand strong { - font-size: 15px; - white-space: nowrap; -} - -.mobile-service { - flex: 0 0 auto; - border: 0; - border-radius: 999px; - background: #ffffff; - color: #1477ff; - padding: 7px 10px; - box-shadow: 0 6px 16px rgba(23, 35, 61, 0.08); - font-size: 13px; - font-weight: 800; -} - -/* ========== van-search 覆盖样式 ========== */ -.home-search { - flex: 1 1 auto; - min-width: 0; - padding: 0; - background: transparent; -} - -.home-search :deep(.van-search__content) { - border-radius: 999px; - border: 1px solid #eef1f5; - background: #ffffff; - padding: 6px 10px; - box-shadow: 0 8px 20px rgba(23, 35, 61, 0.06); -} - -.home-search :deep(.van-search__content .van-field__control) { - color: #17233d; - font-size: 12px; -} - -/* ========== 防骗提示卡片 ========== */ -.fraud-tip { - display: flex; - align-items: center; - gap: 8px; - margin-top: 12px; - border-radius: 12px; - border: 1px solid #ffe6bd; - background: #fffaf0; - padding: 10px 14px; - font-size: 12px; - line-height: 1.45; - color: #5c3b00; -} - -.announcement-swipe { - flex: 1; - height: 18px; - min-width: 0; -} - -.announcement-swipe span { - display: block; - overflow: hidden; - color: #5c3b00; - text-overflow: ellipsis; - white-space: nowrap; -} - -.fraud-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: #f0a500; - flex-shrink: 0; -} - -/* ========== Content 区 ========== */ -.mobile-content { - padding: 8px 14px 14px; -} - -/* ========== Banner 轮播 ========== */ -.banner-swipe { - border-radius: 16px; -} - -.banner-swipe :deep(.van-swipe__indicators) { - bottom: 10px; -} - -.banner-swipe :deep(.van-swipe__indicator) { - width: 5px; - height: 5px; - background: rgba(23, 35, 61, 0.24); - opacity: 1; -} - -.banner-swipe :deep(.van-swipe__indicator--active) { - width: 16px; - border-radius: 999px; - background: #ff6a00; -} - -.mobile-banner { - position: relative; - overflow: hidden; - display: flex; - justify-content: space-between; - gap: 14px; - min-height: 132px; - border-radius: 16px; - background: #ffffff; - padding: 18px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); -} - -.mobile-banner.has-image { - min-height: 132px; - align-items: flex-end; - background: #17233d; - color: #ffffff; -} - -.banner-image { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; -} - -.mobile-banner.has-image::after { - position: absolute; - inset: 0; - content: ""; - background: linear-gradient(90deg, rgba(10, 18, 32, 0.64), rgba(10, 18, 32, 0.12)); -} - -.mobile-banner.has-image > div:not(.banner-badge) { - position: relative; - z-index: 1; - max-width: 78%; -} - -.mobile-banner.tone-green { - background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%); -} - -.mobile-banner.tone-orange { - background: linear-gradient(135deg, #ffffff 0%, #fff7ed 100%); -} - -.mobile-banner p, -.mobile-banner h1 { - margin: 0; -} - -.mobile-banner p { - color: #1477ff; - font-size: 13px; - font-weight: 800; -} - -.mobile-banner.has-image p, -.mobile-banner.has-image h1 { - color: #ffffff; - text-shadow: 0 2px 8px rgba(0, 0, 0, 0.32); -} - -.mobile-banner h1 { - margin-top: 6px; - font-size: 22px; - line-height: 1.22; -} - -.mobile-banner span { - display: inline-flex; - margin-top: 12px; - border-radius: 999px; - background: #ffefb5; - color: #7a4b00; - padding: 6px 10px; - font-size: 12px; - font-weight: 700; -} - -.banner-badge { - position: relative; - z-index: 1; - align-self: flex-start; - border-radius: 12px; - background: #ff6a00; - color: #ffffff; - padding: 8px 9px; - font-size: 12px; - font-weight: 900; - transform: rotate(8deg); -} - -.tone-green .banner-badge { - background: #16a34a; -} - -.tone-orange .banner-badge { - background: #f59e0b; -} - -/* ========== 列表工具栏 ========== */ -.list-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 12px; - border-radius: 14px; - background: #fff; - padding: 14px 16px; - color: #536173; - font-size: 12px; -} - -.sort-entry, -.filter-entry { - border: 0; - background: transparent; - display: flex; - align-items: center; - cursor: pointer; -} - -.sort-entry { - gap: 4px; - color: #17233d; - padding: 0; - font-size: 15px; - font-weight: 900; -} - -.filter-entry { - position: relative; - border-radius: 999px; - color: #17233d; - padding: 0; - gap: 5px; - font-size: 14px; - font-weight: 800; -} - -.filter-entry em { - min-width: 16px; - height: 16px; - border-radius: 999px; - background: #fff; - color: #1477ff; - display: grid; - place-items: center; - font-size: 10px; - font-style: normal; -} - -.sort-panel { - margin-top: 6px; - border-radius: 0 0 14px 14px; - background: #fff; - padding: 4px 16px 14px; -} - -.sort-panel button { - display: flex; - width: 100%; - min-height: 48px; - align-items: center; - justify-content: space-between; - border: 0; - background: transparent; - color: #2d3748; - padding: 0; - font-size: 14px; - font-weight: 700; -} - -.sort-panel button.active { - color: #ff7a00; - font-weight: 900; -} - -.result-count { - margin-top: 8px; - color: #536173; - font-size: 12px; -} - -.result-count strong { - margin-right: 3px; - color: #1477ff; - font-size: 15px; - font-weight: 900; -} - -/* ========== 加载状态 ========== */ -.state-loading { - display: flex; - justify-content: center; - padding: 20px 0; - color: #6b7a90; - font-size: 13px; -} - -/* ========== 卡片列表 ========== */ -.mobile-list { - display: grid; - gap: 12px; - margin-top: 12px; -} - -.mobile-card { - display: grid; - grid-template-columns: 132px minmax(0, 1fr); - grid-template-rows: 132px 56px; - gap: 10px 12px; - min-height: 222px; - border-radius: 16px; - background: #ffffff; - padding: 12px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); - text-decoration: none; - color: inherit; -} - -.card-cover { - position: relative; - display: grid; - width: 132px; - height: 132px; - place-items: center; - overflow: hidden; - align-self: center; - border-radius: 14px; - background: #d8d8d8; - color: #9aa4b2; - font-size: 22px; - font-weight: 900; -} - -.card-cover img { - width: 100%; - height: 100%; - object-fit: cover; - object-position: center; -} - -.card-cover-labels { - position: absolute; - top: 7px; - right: 7px; - display: flex; - align-items: flex-end; - flex-direction: column; - gap: 5px; -} - -.card-cover-labels em { - border-radius: 999px; - background: #ffd21e; - color: #1f2937; - padding: 3px 7px; - font-size: 11px; - font-style: normal; - font-weight: 800; -} - -.card-main { - display: flex; - min-height: 132px; - min-width: 0; - flex-direction: column; - justify-content: center; -} - -.card-title-row { - display: flex; - align-items: flex-start; -} - -.card-title-row h2 { - overflow: hidden; - margin: 0; - color: #182238; - display: -webkit-box; - font-size: 15.5px; - font-weight: 800; - line-height: 1.42; - min-height: 44px; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - min-width: 0; -} - -.card-subtitle { - margin: 5px 0 0; - color: #6b7280; - font-size: 12px; - font-weight: 600; - line-height: 1.35; -} - -.card-badges-row, -.card-chip-row { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.card-badges-row { - margin-top: 9px; -} - -.trust-badge, -.server-badge { - display: inline-flex; - align-items: center; - min-height: 25px; - border-radius: 6px; - padding: 0 7px; - font-size: 12px; - font-weight: 800; -} - -.trust-badge { - background: #fff4dc; - color: #ff7900; -} - -.server-badge { - background: #eaf4ff; - color: #1477ff; -} - -.card-footer { - display: flex; - align-items: flex-end; - justify-content: flex-start; - gap: 10px; - margin-top: 11px; -} - -.price-col { - display: flex; - align-items: baseline; - gap: 8px; - min-width: 0; -} - -.card-footer strong { - color: #ff1f35; - font-size: 21px; - line-height: 1; -} - -.card-footer .rent-sub { - color: #8a96a8; - font-size: 12px; - font-weight: 700; - white-space: nowrap; -} - -.card-chip-row { - grid-column: 1 / -1; - align-content: flex-start; - row-gap: 6px; - column-gap: 6px; - height: 56px; - margin-top: 0; - overflow: hidden; -} - -.card-chip-row span { - display: inline-flex; - align-items: center; - max-width: 100%; - height: 25px; - min-width: 0; - border: 1px solid #ff8a1f; - border-radius: 4px; - color: #ff7900; - padding: 0 5px; - font-size: 11.5px; - font-weight: 700; - line-height: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mobile-load-state { - padding: 4px 0 18px; - color: #8a96a8; - font-size: 12px; - font-weight: 700; - text-align: center; -} - -@media (max-width: 374px) { - .mobile-card { - grid-template-columns: 112px minmax(0, 1fr); - grid-template-rows: 112px 56px; - min-height: 202px; - } - - .card-cover { - width: 112px; - height: 112px; - } - - .card-main { - min-height: 112px; - } - - .card-title-row h2 { - font-size: 14.5px; - } - - .card-footer strong { - font-size: 20px; - } -} - -/* ========== 响应式适配 ========== */ -@media (min-width: 520px) { - .mobile-shell { - max-width: 430px; - margin: 0 auto; - box-shadow: 0 0 0 1px rgba(23, 35, 61, 0.08); - } -} diff --git a/frontend/src/views/mobile/MobileHomeView.vue b/frontend/src/views/mobile/MobileHomeView.vue deleted file mode 100644 index 84ef6e5..0000000 --- a/frontend/src/views/mobile/MobileHomeView.vue +++ /dev/null @@ -1,541 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileListingDetailView.vue b/frontend/src/views/mobile/MobileListingDetailView.vue deleted file mode 100644 index ab54738..0000000 --- a/frontend/src/views/mobile/MobileListingDetailView.vue +++ /dev/null @@ -1,733 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileLoginView.vue b/frontend/src/views/mobile/MobileLoginView.vue deleted file mode 100644 index 9df2b56..0000000 --- a/frontend/src/views/mobile/MobileLoginView.vue +++ /dev/null @@ -1,343 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileMessagesView.vue b/frontend/src/views/mobile/MobileMessagesView.vue deleted file mode 100644 index 53f8e62..0000000 --- a/frontend/src/views/mobile/MobileMessagesView.vue +++ /dev/null @@ -1,317 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileOrderDetailView.vue b/frontend/src/views/mobile/MobileOrderDetailView.vue deleted file mode 100644 index 320e6a6..0000000 --- a/frontend/src/views/mobile/MobileOrderDetailView.vue +++ /dev/null @@ -1,1494 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileOrdersView.vue b/frontend/src/views/mobile/MobileOrdersView.vue deleted file mode 100644 index 2028868..0000000 --- a/frontend/src/views/mobile/MobileOrdersView.vue +++ /dev/null @@ -1,465 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileProfileView.vue b/frontend/src/views/mobile/MobileProfileView.vue deleted file mode 100644 index 95e81f0..0000000 --- a/frontend/src/views/mobile/MobileProfileView.vue +++ /dev/null @@ -1,1081 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileRealnameView.vue b/frontend/src/views/mobile/MobileRealnameView.vue deleted file mode 100644 index ecc634b..0000000 --- a/frontend/src/views/mobile/MobileRealnameView.vue +++ /dev/null @@ -1,266 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileRegisterView.vue b/frontend/src/views/mobile/MobileRegisterView.vue deleted file mode 100644 index bb3ee52..0000000 --- a/frontend/src/views/mobile/MobileRegisterView.vue +++ /dev/null @@ -1,353 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileSellerListingCreateView.css b/frontend/src/views/mobile/MobileSellerListingCreateView.css deleted file mode 100644 index a6302d1..0000000 --- a/frontend/src/views/mobile/MobileSellerListingCreateView.css +++ /dev/null @@ -1,673 +0,0 @@ -.mobile-publish { - width: 100%; - min-width: 0; - min-height: 100dvh; - background: #f4f6f8; - padding-bottom: calc(56px + env(safe-area-inset-bottom)); - overflow-x: hidden; -} - -.page-header { - position: sticky; - top: 0; - z-index: 10; - display: flex; - align-items: center; - height: 48px; - padding: 0 12px; - background: #fff; - border-bottom: 1px solid #eceff3; -} - -.page-header h1 { - flex: 1; - margin: 0; - font-size: 17px; - font-weight: 700; - text-align: center; -} - -.back-btn { - display: grid; - width: 36px; - height: 36px; - place-items: center; - border: none; - background: none; - color: #25282d; - cursor: pointer; -} - -.header-spacer { - width: 36px; -} - -.form-body { - padding: 12px 14px 24px; -} - -.form-section { - padding: 14px; - margin-bottom: 12px; - background: #fff; - border: 1px solid #edf0f4; - border-radius: 8px; -} - -.section-title { - margin: 0 0 10px; - font-size: 14px; - font-weight: 700; - color: #171a1f; -} - -.field-hint { - margin: -2px 0 8px; - color: #858c96; - font-size: 11px; - line-height: 1.5; -} - -.publish-field { - margin-bottom: 8px; - border-radius: 8px; - background: #f8fafc; - overflow: visible; -} - -.publish-field :deep(.van-field__label) { - width: 88px; - color: #30343a; - font-size: 13px; - font-weight: 600; -} - -.publish-field :deep(.van-field__control) { - color: #20242a; - font-size: 14px; -} - -.hint-label, -.backend-hint-target.has-hint, -.upload-title.has-hint { - position: relative; - display: inline-flex; - align-items: center; - max-width: 100%; - line-height: 1.35; - cursor: help; - outline: none; -} - -.hint-label::before, -.backend-hint-target.has-hint::before, -.upload-title.has-hint::before, -.hint-label::after, -.backend-hint-target.has-hint::after, -.upload-title.has-hint::after { - position: absolute; - z-index: 20; - opacity: 0; - visibility: hidden; - pointer-events: none; - transition: opacity 0.16s ease, visibility 0.16s ease; -} - -.hint-label::before, -.backend-hint-target.has-hint::before, -.upload-title.has-hint::before { - content: ""; - top: 50%; - left: calc(100% + 2px); - transform: translateY(-50%); - border: 6px solid transparent; - border-right-color: rgba(32, 36, 42, 0.96); -} - -.hint-label::after, -.backend-hint-target.has-hint::after, -.upload-title.has-hint::after { - content: attr(data-hint); - top: 50%; - left: calc(100% + 14px); - width: min(242px, calc(100vw - 136px)); - max-height: 156px; - overflow: auto; - transform: translateY(-50%); - padding: 8px 10px; - border-radius: 8px; - background: rgba(32, 36, 42, 0.96); - box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18); - color: #fff; - font-size: 12px; - font-weight: 500; - line-height: 1.45; - white-space: normal; -} - -.hint-label:hover::before, -.hint-label:focus-visible::before, -.backend-hint-target.has-hint:hover::before, -.backend-hint-target.has-hint:focus-visible::before, -.upload-title.has-hint:hover::before, -.upload-title.has-hint:focus-visible::before, -.hint-label:hover::after, -.hint-label:focus-visible::after, -.backend-hint-target.has-hint:hover::after, -.backend-hint-target.has-hint:focus-visible::after, -.upload-title.has-hint:hover::after, -.upload-title.has-hint:focus-visible::after { - opacity: 1; - visibility: visible; -} - -.radio-group, -.multi-chip-group { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.radio-group.small { - gap: 6px; -} - -.radio-btn { - min-height: 28px; - padding: 5px 12px; - border: 1px solid #d7dce3; - border-radius: 14px; - background: #fff; - color: #5c6470; - font-size: 12px; - font-weight: 600; - line-height: 1; -} - -.radio-btn.active { - border-color: #1477ff; - background: #1477ff; - color: #fff; -} - -.time-row, -.result-grid { - display: grid; - grid-template-columns: 1fr; - gap: 8px; -} - -.level-panel { - display: grid; - gap: 8px; -} - -.level-row { - display: grid; - grid-template-columns: 58px minmax(0, 1fr); - gap: 8px; - align-items: center; - padding: 10px; - border-radius: 8px; - background: #f8fafc; -} - -.level-label { - color: #30343a; - font-size: 13px; - font-weight: 700; - white-space: nowrap; -} - -.level-label span { - margin-right: 2px; - color: #ee0a24; -} - -.level-options { - display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 6px; -} - -.level-btn { - min-width: 0; - height: 30px; - padding: 0; - border: 1px solid #d7dce3; - border-radius: 15px; - background: #fff; - color: #5c6470; - font-size: 12px; - font-weight: 700; - line-height: 1; -} - -.level-btn.active { - border-color: #1477ff; - background: #1477ff; - color: #fff; -} - -.quantity-row { - display: grid; - grid-template-columns: minmax(0, 1fr) 92px; - gap: 8px; - align-items: stretch; - margin-bottom: 8px; -} - -.quantity-row.disabled { - opacity: 0.62; -} - -.quantity-card { - display: grid; - grid-template-columns: minmax(0, 1fr) 72px; - gap: 10px; - align-items: center; - min-height: 72px; - padding: 10px 12px; - border-radius: 8px; - background: #f8fafc; -} - -.quantity-meta { - display: grid; - min-width: 0; - gap: 5px; -} - -.quantity-meta strong { - overflow: hidden; - color: #30343a; - font-size: 13px; - font-weight: 700; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; -} - -.quantity-meta span { - margin-right: 2px; - color: #ee0a24; -} - -.quantity-meta small { - color: #858c96; - font-size: 11px; - font-weight: 600; -} - -.quantity-input { - width: 100%; - min-width: 0; - height: 36px; - padding: 0 8px; - border: 1px solid #e0e5ec; - border-radius: 8px; - background: #fff; - color: #20242a; - font-size: 16px; - font-weight: 700; - text-align: center; - outline: none; -} - -.quantity-input:focus { - border-color: #1477ff; -} - -.quantity-input:disabled { - cursor: not-allowed; -} - -.mode-toggle { - display: grid; - grid-template-columns: 1fr; - gap: 6px; - min-height: 72px; -} - -.mode-toggle button { - border: 1px solid #d7dce3; - border-radius: 8px; - background: #fff; - color: #606873; - font-size: 12px; - font-weight: 700; -} - -.mode-toggle button.active { - border-color: #ff6a00; - background: #fff3ea; - color: #e65f00; -} - -.mode-toggle button:disabled { - cursor: not-allowed; -} - -.skin-groups { - display: grid; - gap: 10px; - margin-bottom: 8px; -} - -.skin-group { - padding: 10px; - border-radius: 8px; - background: #f8fafc; -} - -.skin-group-title { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - color: #30343a; - font-size: 13px; - font-weight: 700; -} - -.skin-group-title small { - color: #858c96; - font-size: 11px; - font-weight: 600; -} - -.region-panel { - display: grid; - gap: 8px; - padding: 10px; - border-radius: 8px; - background: #f8fafc; -} - -.region-title { - display: flex; - align-items: center; - justify-content: space-between; - color: #30343a; - font-size: 13px; - font-weight: 700; -} - -.region-title small { - color: #858c96; - font-size: 11px; - font-weight: 600; -} - -.region-grid { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 6px; -} - -.region-btn { - min-width: 0; - height: 28px; - padding: 0 2px; - border: 1px solid #d7dce3; - border-radius: 14px; - background: #fff; - color: #5c6470; - font-size: 11px; - font-weight: 700; -} - -.region-btn.active { - border-color: #1477ff; - background: #1477ff; - color: #fff; -} - -.upload-list { - display: grid; - gap: 10px; -} - -.upload-line { - display: grid; - grid-template-columns: minmax(0, 1fr) 86px; - gap: 10px; - align-items: center; - min-height: 76px; - padding: 10px; - border: 1px solid #edf0f4; - border-radius: 8px; - background: #f8fafc; -} - -.upload-meta { - display: grid; - gap: 4px; -} - -.upload-meta strong { - color: #242830; - font-size: 13px; -} - -.upload-meta span { - color: #f04438; -} - -.upload-meta small { - color: #858c96; - font-size: 11px; - line-height: 1.4; -} - -.upload-add, -.upload-preview { - width: 76px; - height: 76px; - border-radius: 8px; -} - -.upload-add { - display: grid; - place-items: center; - border: 1px dashed #c7cdd6; - background: #fff; - color: #7b8490; - font-size: 11px; -} - -.upload-add:disabled { - opacity: 0.5; -} - -.upload-preview { - position: relative; - overflow: hidden; - background: #e8ecf1; -} - -.upload-preview img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.upload-preview button { - position: absolute; - right: 0; - bottom: 0; - height: 24px; - padding: 0 8px; - border: none; - border-radius: 8px 0 0; - background: rgba(0, 0, 0, 0.62); - color: #fff; - font-size: 11px; -} - -.result-field :deep(.van-field__control) { - color: #ff6a00; - font-weight: 800; -} - -.ratio-panel { - display: grid; - gap: 10px; - margin-bottom: 8px; -} - -.deposit-recommend-btn { - min-height: 32px; - margin: -2px 0 10px 88px; - padding: 0 12px; - border: 1px solid #ffba86; - border-radius: 16px; - background: #fff7f0; - color: #e65f00; - font-size: 12px; - font-weight: 800; -} - -.ratio-reference { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - min-height: 48px; - padding: 10px 12px; - border-radius: 8px; - background: #f8fafc; -} - -.ratio-reference span { - color: #30343a; - font-size: 13px; - font-weight: 700; -} - -.ratio-reference strong { - color: #ff6a00; - font-size: 18px; - font-weight: 900; - line-height: 1; -} - -.ratio-range { - margin: 0; - color: #858c96; - font-size: 11px; - font-weight: 600; - line-height: 1.5; -} - -.ratio-mode-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.ratio-mode-btn { - display: grid; - gap: 5px; - min-width: 0; - min-height: 74px; - padding: 12px 8px; - border: 1px solid #d7dce3; - border-radius: 8px; - background: #fff; - color: #30343a; - text-align: center; -} - -.ratio-mode-btn strong, -.ratio-mode-btn span { - min-width: 0; - overflow-wrap: anywhere; -} - -.ratio-mode-btn strong { - font-size: 14px; - font-weight: 800; -} - -.ratio-mode-btn span { - color: #858c96; - font-size: 12px; - font-weight: 700; - line-height: 1.25; -} - -.ratio-mode-btn.active { - border-color: #ff6a00; - background: #fff7f0; - color: #ff6a00; -} - -.ratio-mode-btn.active span { - color: #ff6a00; -} - -.ratio-mode-btn:disabled { - cursor: not-allowed; - opacity: 0.58; -} - -.ratio-input-field { - margin-bottom: 0; -} - -.ratio-input-hint { - margin: -2px 0 0; -} - -.total-price-field :deep(.van-field__control) { - font-size: 16px; -} - -.price-breakdown-hint { - margin: -2px 0 10px; - color: #858c96; - font-size: 11px; - line-height: 1.5; -} - -.publish-actions { - display: grid; - grid-template-columns: 92px minmax(0, 1fr); - gap: 10px; - margin-top: 8px; -} - -.reset-btn, -.submit-btn { - height: 44px; - width: 100%; - border: none !important; - font-size: 15px; - font-weight: 700; -} - -.reset-btn { - border: 1px solid #d7dce3 !important; - background: #fff !important; - color: #4b5563 !important; -} - -.submit-btn { - background: #ff6a00 !important; -} - - - -@media (min-width: 430px) { - .time-row { - grid-template-columns: 1fr 1fr; - } -} - -@media (max-width: 370px) { - .level-options, - .region-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } -} diff --git a/frontend/src/views/mobile/MobileSellerListingCreateView.vue b/frontend/src/views/mobile/MobileSellerListingCreateView.vue deleted file mode 100644 index f8d1947..0000000 --- a/frontend/src/views/mobile/MobileSellerListingCreateView.vue +++ /dev/null @@ -1,597 +0,0 @@ - - - - - diff --git a/frontend/src/views/mobile/MobileSellerListingsView.vue b/frontend/src/views/mobile/MobileSellerListingsView.vue deleted file mode 100644 index 4cf607e..0000000 --- a/frontend/src/views/mobile/MobileSellerListingsView.vue +++ /dev/null @@ -1,506 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/HomeView.vue b/frontend/src/views/public/HomeView.vue deleted file mode 100644 index 8af136c..0000000 --- a/frontend/src/views/public/HomeView.vue +++ /dev/null @@ -1,262 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/HomeView.vue.backup b/frontend/src/views/public/HomeView.vue.backup deleted file mode 100644 index a1ed134..0000000 --- a/frontend/src/views/public/HomeView.vue.backup +++ /dev/null @@ -1,2078 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/ListingDetailView.vue b/frontend/src/views/public/ListingDetailView.vue deleted file mode 100644 index d69604f..0000000 --- a/frontend/src/views/public/ListingDetailView.vue +++ /dev/null @@ -1,749 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/ListingsView.vue b/frontend/src/views/public/ListingsView.vue deleted file mode 100644 index c261255..0000000 --- a/frontend/src/views/public/ListingsView.vue +++ /dev/null @@ -1,1389 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/components/HomeAnnouncement.vue b/frontend/src/views/public/components/HomeAnnouncement.vue deleted file mode 100644 index 365d08b..0000000 --- a/frontend/src/views/public/components/HomeAnnouncement.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/components/HomeBanner.vue b/frontend/src/views/public/components/HomeBanner.vue deleted file mode 100644 index 2d8c37f..0000000 --- a/frontend/src/views/public/components/HomeBanner.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/components/HomeFilters.vue b/frontend/src/views/public/components/HomeFilters.vue deleted file mode 100644 index 4d02ed3..0000000 --- a/frontend/src/views/public/components/HomeFilters.vue +++ /dev/null @@ -1,207 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/components/HomeStats.vue b/frontend/src/views/public/components/HomeStats.vue deleted file mode 100644 index a4c8669..0000000 --- a/frontend/src/views/public/components/HomeStats.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/components/HomeZonesAndSort.vue b/frontend/src/views/public/components/HomeZonesAndSort.vue deleted file mode 100644 index f78892e..0000000 --- a/frontend/src/views/public/components/HomeZonesAndSort.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/components/ListingCard.vue b/frontend/src/views/public/components/ListingCard.vue deleted file mode 100644 index cbfff16..0000000 --- a/frontend/src/views/public/components/ListingCard.vue +++ /dev/null @@ -1,344 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/components/RangeFilter.vue b/frontend/src/views/public/components/RangeFilter.vue deleted file mode 100644 index 1d8db13..0000000 --- a/frontend/src/views/public/components/RangeFilter.vue +++ /dev/null @@ -1,100 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/components/SkinFilter.vue b/frontend/src/views/public/components/SkinFilter.vue deleted file mode 100644 index 5af5364..0000000 --- a/frontend/src/views/public/components/SkinFilter.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - - - diff --git a/frontend/src/views/public/components/StringFilter.vue b/frontend/src/views/public/components/StringFilter.vue deleted file mode 100644 index 17ff63f..0000000 --- a/frontend/src/views/public/components/StringFilter.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - - - diff --git a/frontend/src/views/seller/SellerEarningsView.vue b/frontend/src/views/seller/SellerEarningsView.vue deleted file mode 100644 index c59164e..0000000 --- a/frontend/src/views/seller/SellerEarningsView.vue +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/frontend/src/views/seller/SellerHandoffsView.vue b/frontend/src/views/seller/SellerHandoffsView.vue deleted file mode 100644 index 8755ca5..0000000 --- a/frontend/src/views/seller/SellerHandoffsView.vue +++ /dev/null @@ -1,130 +0,0 @@ - - - diff --git a/frontend/src/views/seller/SellerListingCreateView.css b/frontend/src/views/seller/SellerListingCreateView.css deleted file mode 100644 index e0dc8c1..0000000 --- a/frontend/src/views/seller/SellerListingCreateView.css +++ /dev/null @@ -1,828 +0,0 @@ -/* 设计系统变量定义 */ -.publish-page { - --color-bg-gray: #f7f9fc; - --color-bg-orange: #fff7f0; - --color-orange-primary: #ff6a00; - --color-orange-dark: #e65f00; - --color-text-main: #303743; - --color-text-sub: #7f8895; - --color-text-dark: #20242a; - --color-border: #dfe5ee; - --color-danger: #f04438; - --radius-8: 8px; - - min-width: 0; - color: var(--color-text-dark); - - :deep(.el-input__wrapper), - :deep(.el-textarea__inner) { - border-radius: var(--radius-8); - box-shadow: 0 0 0 1px var(--color-border) inset; - transition: all 0.2s ease; - } - - :deep(.el-input__wrapper:hover), - :deep(.el-textarea__inner:hover) { - box-shadow: 0 0 0 1px var(--color-orange-primary) inset !important; - } - - :deep(.el-input__wrapper.is-focus), - :deep(.el-textarea__inner:focus) { - box-shadow: 0 0 0 1px var(--color-orange-primary) inset, 0 0 8px rgba(255, 106, 0, 0.15) !important; - } -} - -.publish-layout { - display: grid; - grid-template-columns: minmax(760px, 1fr) 300px; - align-items: start; - gap: 24px; - max-width: 1580px; - margin: 0 auto; - overflow: visible; - - @media (max-width: 1120px) { - grid-template-columns: 1fr; - max-width: 920px; - } -} - -.form-column { - display: grid; - min-width: 0; - gap: 18px; -} - -/* 统合的灰色圆角面板基础类 */ -.panel-field-row, -.panel-input-grid, -.level-row, -.login-method-row, -.quantity-item, -.skin-group, -.region-panel, -.ratio-panel, -.time-preset-row, -.price-cell { - padding: 12px; - border-radius: var(--radius-8); - background: var(--color-bg-gray); -} - -.panel-input-grid { - align-items: center; - - & .input-block { - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: 10px; - } - - /* 输入框不需要太长,限制最大宽度 */ - & :deep(.el-input-number), - & :deep(.el-input) { - max-width: 140px; - } -} - -.summary-panel { - border: 1px solid #e6ebf2; - border-radius: var(--radius-8); - background: #fff; - box-shadow: 0 12px 34px rgba(20, 34, 56, 0.05); -} - -.field-row { - display: grid; - grid-template-columns: 112px minmax(0, 1fr); - gap: 14px; - align-items: center; - margin-top: 12px; - - @media (max-width: 860px) { - grid-template-columns: 1fr; - } - - & > label { - color: var(--color-text-main); - font-size: 13px; - font-weight: 800; - line-height: 22px; - } - - & label span { - color: var(--color-danger); - } -} - -/* 按钮共用样式 */ -.level-btn, -.region-btn, -.mode-toggle button, -.ratio-mode-btn { - border: 1px solid #d6dce5; - background: #fff; - color: #596474; - font-weight: 800; - cursor: pointer; - transition: all 0.16s ease; -} - -.level-btn, -.region-btn { - &.active { - border-color: var(--color-orange-primary); - background: var(--color-orange-primary); - color: #fff; - } -} - -.level-btn { - height: 30px; - min-width: 0; - border-radius: 15px; -} - -.region-btn { - height: 32px; - min-width: 0; - border-radius: 16px; -} - -.compact-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 16px; - margin-top: 16px; - - &.two { - grid-template-columns: repeat(2, minmax(0, 1fr)); - padding: 14px 16px; - border-radius: var(--radius-8); - background: var(--color-bg-gray); - gap: 16px; - } - - @media (max-width: 860px) { - grid-template-columns: 1fr; - } -} - -.input-block { - display: grid; - min-width: 0; - gap: 8px; - - & > span { - color: var(--color-text-main); - font-size: 13px; - font-weight: 800; - line-height: 22px; - white-space: nowrap; - } - - & b { - color: var(--color-danger); - } - - :deep(.el-input-number), - :deep(.el-input), - :deep(.el-date-editor) { - width: 100%; - } -} - -.level-panel { - display: grid; - gap: 10px; - margin-top: 16px; -} - -.level-row { - grid-template-columns: 72px minmax(0, 1fr); - gap: 12px; - align-items: center; - padding: 10px 12px; - - & strong { - font-size: 13px; - - & span { - color: var(--color-danger); - } - } -} - -.level-options { - display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 9px; -} - -.field-hint { - grid-column: 2; - margin: 0; - color: var(--color-text-sub); - font-size: 12px; - line-height: 1.55; - - @media (max-width: 860px) { - grid-column: auto; - } -} - -.login-method-row { - & .field-hint { - margin-top: -2px; - } -} - -.quantity-table { - display: grid; - gap: 8px; -} - -.quantity-header, -.quantity-item { - display: grid; - grid-template-columns: minmax(180px, 3fr) minmax(80px, 1fr) minmax(100px, 1.2fr) minmax(140px, 1.5fr) minmax(80px, 1fr); - gap: 10px; - align-items: center; - - @media (max-width: 860px) { - grid-template-columns: 1fr; - } -} - -.quantity-header { - min-height: 30px; - padding: 0 12px; - color: #7b8492; - font-size: 12px; - font-weight: 800; - - @media (max-width: 860px) { - display: none; - } - - & b { - color: var(--color-danger); - margin-left: 2px; - } -} - -.quantity-item { - min-height: 58px; - - &.disabled { - opacity: 0.62; - } - - /* 强制 el-input-number 所在的 Grid 单元格裁剪溢出 */ - & > :deep(.el-input-number) { - width: 100% !important; - min-width: 0 !important; - overflow: hidden; - } -} - -.quantity-meta { - display: grid; - min-width: 0; - gap: 4px; - - & strong { - overflow: hidden; - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; - - & span { - color: var(--color-danger); - margin-right: 2px; - } - } - - & small { - color: var(--color-text-sub); - font-size: 12px; - font-weight: 700; - } -} - -.quantity-price, -.quantity-status { - white-space: nowrap; - color: var(--color-text-sub); - font-size: 12px; - font-weight: 700; -} - -.mode-toggle { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 4px; - min-width: 0; - - & button { - height: 30px; - min-width: 0; - border-radius: var(--radius-8); - font-size: 12px; - white-space: nowrap; - padding: 0 6px; - - &:disabled { - cursor: not-allowed; - } - - &.active { - border-color: var(--color-orange-primary); - background: #fff3ea; - color: var(--color-orange-dark); - } - } -} - -.recommend-button { - flex: 0 0 auto; - height: 32px; - padding: 0 12px; - border: 1px solid #ffba86; - border-radius: var(--radius-8); - background: var(--color-bg-orange); - color: var(--color-orange-dark); - font-size: 12px; - font-weight: 800; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background: #ffe3cd; - border-color: var(--color-orange-primary); - } -} - -.deposit-control { - display: flex; - gap: 8px; - align-items: center; - - & :deep(.el-input-number) { - width: 140px !important; - flex: 0 0 140px; - } - - @media (max-width: 860px) { - width: 100%; - & :deep(.el-input-number) { - width: 100% !important; - flex: 1; - } - } -} - -.deposit-breakdown { - display: flex; - flex-wrap: wrap; - gap: 6px; - - & span { - min-height: 24px; - padding: 4px 8px; - border-radius: 12px; - background: var(--color-bg-orange); - color: var(--color-orange-dark); - font-size: 12px; - font-weight: 800; - } -} - -.skin-groups { - display: grid; - gap: 10px; -} - -.skin-group { - border: none; -} - -.skin-title, -.region-title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - font-size: 13px; - - & span { - color: var(--color-text-sub); - font-size: 12px; - font-weight: 700; - } -} - -.skin-title { - min-height: 34px; - margin-bottom: 8px; -} - -.time-preset-panel { - display: grid; - gap: 10px; - margin-top: 16px; -} - -.time-preset-row { - display: grid; - grid-template-columns: 88px minmax(0, 1fr); - gap: 12px; - align-items: start; - - @media (max-width: 860px) { - grid-template-columns: 1fr; - } - - & strong { - color: var(--color-text-main); - font-size: 13px; - font-weight: 800; - line-height: 32px; - } -} - -.time-preset-content { - display: grid; - gap: 8px; - min-width: 0; -} - -.time-input { - width: 118px; - - @media (max-width: 860px) { - width: 140px; - } -} - -.login-region-panel { - margin-top: 18px; -} - -.region-grid { - display: grid; - grid-template-columns: repeat(8, minmax(0, 1fr)); - gap: 8px; - - @media (max-width: 860px) { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } -} - -.upload-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - - @media (max-width: 860px) { - grid-template-columns: 1fr; - } -} - -.upload-item { - display: grid; - grid-template-columns: minmax(0, 1fr) 96px; - gap: 12px; - align-items: center; - min-height: 112px; - border: 1px solid var(--color-border-light); - - @media (max-width: 860px) { - grid-template-columns: 1fr; - } -} - -.upload-copy { - display: grid; - min-width: 0; - gap: 6px; - - & strong { - color: #232a35; - font-size: 14px; - - & span { - color: var(--color-danger); - } - } - - & small { - color: var(--color-text-sub); - font-size: 12px; - font-weight: 700; - line-height: 1.45; - } -} - -.upload-add, -.upload-preview { - width: 96px; - height: 88px; - border-radius: var(--radius-8); - - @media (max-width: 860px) { - width: 100%; - } -} - -.upload-add { - display: grid; - place-items: center; - border: 1px dashed #c7ceda; - background: #fff; - color: #727d8d; - font-size: 12px; - font-weight: 800; - cursor: pointer; - - &:disabled { - cursor: not-allowed; - opacity: 0.58; - } -} - -.upload-preview { - position: relative; - overflow: hidden; - background: #e8edf4; - - & img { - width: 100%; - height: 100%; - object-fit: cover; - } - - & button { - position: absolute; - right: 6px; - bottom: 6px; - display: grid; - width: 28px; - height: 28px; - place-items: center; - border: none; - border-radius: var(--radius-8); - background: rgba(17, 24, 39, 0.72); - color: #fff; - cursor: pointer; - } -} - -.hidden-file { - display: none; -} - -.ratio-panel { - margin-top: 14px; - - & p { - margin: 0; - color: var(--color-text-sub); - font-size: 12px; - line-height: 1.5; - } -} - -.ratio-reference { - display: flex; - align-items: center; - justify-content: space-between; - min-height: 52px; - padding: 12px; - border-radius: var(--radius-8); - background: #fff; - - & span { - color: var(--color-text-main); - font-size: 13px; - font-weight: 800; - } - - & strong { - color: var(--color-orange-primary); - font-weight: 900; - font-size: 22px; - } -} - -.ratio-mode-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; - - @media (max-width: 860px) { - grid-template-columns: 1fr; - } -} - -.ratio-custom-card { - display: flex; - flex-direction: column; - justify-content: center; - gap: 6px; - min-height: 72px; - padding: 10px 12px; - border: 1px solid #d6dce5; - border-radius: var(--radius-8); - background: #fff; - transition: all 0.16s ease; - - & strong { - color: #596474; - font-size: 13px; - font-weight: 800; - } - - & :deep(.el-input-number) { - width: 100% !important; - } - - & :deep(.el-input__wrapper) { - background-color: #fff !important; - } - - &.active { - border-color: var(--color-orange-primary); - background: var(--color-bg-orange); - color: var(--color-orange-primary); - - & strong { - color: var(--color-orange-primary); - } - } -} - -.ratio-mode-btn { - display: grid; - gap: 6px; - min-height: 72px; - padding: 12px; - text-align: left; - border-radius: var(--radius-8); - - & span { - color: var(--color-text-sub); - font-size: 12px; - } - - &.active { - border-color: var(--color-orange-primary); - background: var(--color-bg-orange); - color: var(--color-orange-primary); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.58; - } -} - -.price-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 10px; - margin-top: 14px; - - @media (max-width: 860px) { - grid-template-columns: 1fr; - } -} - -.price-cell { - display: grid; - gap: 6px; - min-height: 82px; - padding: 14px; - border-left: 4px solid #ccd5e0; - transition: all 0.2s ease; - - &:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03); - } - - & span { - color: #6f7a89; - font-size: 12px; - font-weight: 800; - } - - & strong { - overflow-wrap: anywhere; - color: var(--color-orange-primary); - font-weight: 900; - font-size: 21px; - } - - &.accent { - background: var(--color-bg-orange); - border-left-color: var(--color-orange-primary); - } -} - -.remark { - margin-top: 14px; -} - -.summary-column { - --summary-sticky-top: clamp(148px, 22vh, 240px); - - min-width: 0; - position: sticky; - top: var(--summary-sticky-top); - align-self: start; - max-height: calc(100vh - var(--summary-sticky-top) - 24px); - - @media (max-width: 1120px) { - display: none; - } -} - -.summary-panel { - padding: 16px; - max-height: inherit; - overflow-y: auto; - overscroll-behavior: contain; -} - -.summary-main { - display: grid; - gap: 8px; - padding: 16px; - border-radius: var(--radius-8); - background: var(--color-bg-orange); - - & span { - color: #6f7a89; - font-size: 12px; - font-weight: 800; - } - - & strong { - color: var(--color-orange-primary); - font-weight: 900; - font-size: 34px; - line-height: 1; - } -} - -.summary-list { - display: grid; - gap: 10px; - margin: 14px 0; - - & span { - color: #6f7a89; - font-size: 12px; - font-weight: 800; - } - - & div { - display: flex; - justify-content: space-between; - gap: 12px; - padding-bottom: 10px; - border-bottom: 1px solid #eef2f6; - } - - & strong { - color: var(--color-text-dark); - font-size: 13px; - text-align: right; - overflow-wrap: anywhere; - } -} - -.summary-actions { - display: grid; - gap: 10px; - - & :deep(.el-button) { - width: 100%; - margin-left: 0; - border-radius: var(--radius-8); - } - - & :deep(.el-button.btn-publish) { - background-color: var(--color-orange-primary); - border-color: var(--color-orange-primary); - color: #fff; - font-weight: 800; - transition: all 0.2s ease; - - &:hover { - background-color: var(--color-orange-dark); - border-color: var(--color-orange-dark); - } - } -} diff --git a/frontend/src/views/seller/SellerListingCreateView.vue b/frontend/src/views/seller/SellerListingCreateView.vue deleted file mode 100644 index e2c7ed1..0000000 --- a/frontend/src/views/seller/SellerListingCreateView.vue +++ /dev/null @@ -1,490 +0,0 @@ - - - - - diff --git a/frontend/src/views/seller/SellerListingsView.vue b/frontend/src/views/seller/SellerListingsView.vue deleted file mode 100644 index 903cd26..0000000 --- a/frontend/src/views/seller/SellerListingsView.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - diff --git a/frontend/src/views/seller/components/OptionChips.vue b/frontend/src/views/seller/components/OptionChips.vue deleted file mode 100644 index 56b769a..0000000 --- a/frontend/src/views/seller/components/OptionChips.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - - - diff --git a/frontend/src/views/seller/components/PublishSection.vue b/frontend/src/views/seller/components/PublishSection.vue deleted file mode 100644 index ec8ea9f..0000000 --- a/frontend/src/views/seller/components/PublishSection.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - -