From c9397635e27ef8a95e6449182759c7af69ced272 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 4 Jun 2026 09:05:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20P3=E9=98=B6=E6=AE=B5=E5=AE=8C=E6=88=90?= =?UTF-8?q?=20-=20=E5=85=A8=E9=83=A8=E6=A8=A1=E5=9D=97=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=AE=8C=E6=88=90=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## P3.1: 争议仲裁模块(disputes)✅ - API: disputes.ts - 模块导出 ## P3.2: 卖家中心模块(seller)✅ - Views: 4个页面 - Composables: usePublishForm, usePublishDraft - 模块导出 ## P3.3: 管理后台模块(admin)✅ - API: 8个文件(adminAuth, adminDashboard, adminUsers等) - Views: 15个管理页面 - Composables: useAdminTable, useAdminPaginatedTable - Components: 管理端组件 - 模块导出 --- ## 🎉 Features 架构迁移全部完成! ### 最终统计 - ✅ P0: shared(基础设施)- 22个文件 - ✅ P1: wallet, chats, orders - 24个文件 - ✅ P2: listings, auth - 35个文件 - ✅ P3: seller, disputes, admin - 47个文件 **总计:** 9个模块,128个文件完成迁移 ### 新架构 ``` frontend/src/ ├── features/ # 9个业务模块 ✅ │ ├── wallet/ ✅ │ ├── chats/ ✅ │ ├── orders/ ✅ (已重构) │ ├── listings/ ✅ │ ├── auth/ ✅ │ ├── seller/ ✅ │ ├── disputes/ ✅ │ └── admin/ ✅ └── shared/ ✅ ``` 下一步:清理旧文件、更新路由配置 Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/features/admin/api/adminAudit.ts | 32 + frontend/src/features/admin/api/adminAuth.ts | 74 ++ .../src/features/admin/api/adminDashboard.ts | 61 ++ frontend/src/features/admin/api/adminMgr.ts | 76 ++ frontend/src/features/admin/api/adminRoles.ts | 70 ++ frontend/src/features/admin/api/adminUsers.ts | 37 + .../src/features/admin/api/adminWallet.ts | 36 + .../src/features/admin/api/systemConfigs.ts | 22 + .../admin/components/AdminUserDialog.vue | 124 +++ .../components/AssignPermissionsDialog.vue | 177 ++++ .../admin/components/AssignRolesDialog.vue | 115 +++ .../admin/components/AutoWelcomeConfig.vue | 136 +++ .../admin/components/GeneralConfigDialog.vue | 134 +++ .../components/HomeAnnouncementsDialog.vue | 153 +++ .../admin/components/HomeBannersDialog.vue | 286 ++++++ .../admin/components/PublishOptionsDialog.vue | 574 +++++++++++ .../admin/components/QuickReplyDialog.vue | 281 +++++ .../features/admin/components/RoleDialog.vue | 116 +++ .../admin/components/SalePriceDialog.vue | 216 ++++ .../admin/components/TransferDialog.vue | 195 ++++ .../composables/useAdminPaginatedTable.ts | 52 + .../admin/composables/useAdminTable.ts | 42 + frontend/src/features/admin/index.ts | 11 + .../admin/views/AdminAuditLogsView.vue | 170 ++++ .../features/admin/views/AdminChatsView.vue | 751 ++++++++++++++ .../admin/views/AdminDashboardView.vue | 494 +++++++++ .../admin/views/AdminDisputesView.vue | 192 ++++ .../admin/views/AdminListingDetailView.vue | 178 ++++ .../admin/views/AdminListingReviewView.vue | 963 ++++++++++++++++++ .../admin/views/AdminListingsView.vue | 468 +++++++++ .../features/admin/views/AdminLoginView.vue | 434 ++++++++ .../admin/views/AdminMgrUsersView.vue | 205 ++++ .../admin/views/AdminOrderDetailView.vue | 202 ++++ .../features/admin/views/AdminOrdersView.vue | 78 ++ .../features/admin/views/AdminRolesView.vue | 108 ++ .../admin/views/AdminSystemConfigsView.vue | 406 ++++++++ .../features/admin/views/AdminUsersView.vue | 114 +++ .../admin/views/AdminWalletLedgerView.vue | 164 +++ .../src/features/disputes/api/disputes.ts | 47 + frontend/src/features/disputes/index.ts | 2 + .../seller/composables/usePublishDraft.ts | 124 +++ .../seller/composables/usePublishForm.ts | 568 +++++++++++ frontend/src/features/seller/index.ts | 3 + .../seller/views/SellerEarningsView.vue | 9 + .../seller/views/SellerHandoffsView.vue | 130 +++ .../seller/views/SellerListingCreateView.vue | 490 +++++++++ .../seller/views/SellerListingsView.vue | 86 ++ 47 files changed, 9406 insertions(+) create mode 100644 frontend/src/features/admin/api/adminAudit.ts create mode 100644 frontend/src/features/admin/api/adminAuth.ts create mode 100644 frontend/src/features/admin/api/adminDashboard.ts create mode 100644 frontend/src/features/admin/api/adminMgr.ts create mode 100644 frontend/src/features/admin/api/adminRoles.ts create mode 100644 frontend/src/features/admin/api/adminUsers.ts create mode 100644 frontend/src/features/admin/api/adminWallet.ts create mode 100644 frontend/src/features/admin/api/systemConfigs.ts create mode 100644 frontend/src/features/admin/components/AdminUserDialog.vue create mode 100644 frontend/src/features/admin/components/AssignPermissionsDialog.vue create mode 100644 frontend/src/features/admin/components/AssignRolesDialog.vue create mode 100644 frontend/src/features/admin/components/AutoWelcomeConfig.vue create mode 100644 frontend/src/features/admin/components/GeneralConfigDialog.vue create mode 100644 frontend/src/features/admin/components/HomeAnnouncementsDialog.vue create mode 100644 frontend/src/features/admin/components/HomeBannersDialog.vue create mode 100644 frontend/src/features/admin/components/PublishOptionsDialog.vue create mode 100644 frontend/src/features/admin/components/QuickReplyDialog.vue create mode 100644 frontend/src/features/admin/components/RoleDialog.vue create mode 100644 frontend/src/features/admin/components/SalePriceDialog.vue create mode 100644 frontend/src/features/admin/components/TransferDialog.vue create mode 100644 frontend/src/features/admin/composables/useAdminPaginatedTable.ts create mode 100644 frontend/src/features/admin/composables/useAdminTable.ts create mode 100644 frontend/src/features/admin/index.ts create mode 100644 frontend/src/features/admin/views/AdminAuditLogsView.vue create mode 100644 frontend/src/features/admin/views/AdminChatsView.vue create mode 100644 frontend/src/features/admin/views/AdminDashboardView.vue create mode 100644 frontend/src/features/admin/views/AdminDisputesView.vue create mode 100644 frontend/src/features/admin/views/AdminListingDetailView.vue create mode 100644 frontend/src/features/admin/views/AdminListingReviewView.vue create mode 100644 frontend/src/features/admin/views/AdminListingsView.vue create mode 100644 frontend/src/features/admin/views/AdminLoginView.vue create mode 100644 frontend/src/features/admin/views/AdminMgrUsersView.vue create mode 100644 frontend/src/features/admin/views/AdminOrderDetailView.vue create mode 100644 frontend/src/features/admin/views/AdminOrdersView.vue create mode 100644 frontend/src/features/admin/views/AdminRolesView.vue create mode 100644 frontend/src/features/admin/views/AdminSystemConfigsView.vue create mode 100644 frontend/src/features/admin/views/AdminUsersView.vue create mode 100644 frontend/src/features/admin/views/AdminWalletLedgerView.vue create mode 100644 frontend/src/features/disputes/api/disputes.ts create mode 100644 frontend/src/features/disputes/index.ts create mode 100644 frontend/src/features/seller/composables/usePublishDraft.ts create mode 100644 frontend/src/features/seller/composables/usePublishForm.ts create mode 100644 frontend/src/features/seller/index.ts create mode 100644 frontend/src/features/seller/views/SellerEarningsView.vue create mode 100644 frontend/src/features/seller/views/SellerHandoffsView.vue create mode 100644 frontend/src/features/seller/views/SellerListingCreateView.vue create mode 100644 frontend/src/features/seller/views/SellerListingsView.vue diff --git a/frontend/src/features/admin/api/adminAudit.ts b/frontend/src/features/admin/api/adminAudit.ts new file mode 100644 index 0000000..3876ce9 --- /dev/null +++ b/frontend/src/features/admin/api/adminAudit.ts @@ -0,0 +1,32 @@ +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/features/admin/api/adminAuth.ts b/frontend/src/features/admin/api/adminAuth.ts new file mode 100644 index 0000000..afb9cb8 --- /dev/null +++ b/frontend/src/features/admin/api/adminAuth.ts @@ -0,0 +1,74 @@ +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/features/admin/api/adminDashboard.ts b/frontend/src/features/admin/api/adminDashboard.ts new file mode 100644 index 0000000..2896310 --- /dev/null +++ b/frontend/src/features/admin/api/adminDashboard.ts @@ -0,0 +1,61 @@ +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/features/admin/api/adminMgr.ts b/frontend/src/features/admin/api/adminMgr.ts new file mode 100644 index 0000000..adba43c --- /dev/null +++ b/frontend/src/features/admin/api/adminMgr.ts @@ -0,0 +1,76 @@ +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/features/admin/api/adminRoles.ts b/frontend/src/features/admin/api/adminRoles.ts new file mode 100644 index 0000000..21f6916 --- /dev/null +++ b/frontend/src/features/admin/api/adminRoles.ts @@ -0,0 +1,70 @@ +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/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts new file mode 100644 index 0000000..dbc396c --- /dev/null +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -0,0 +1,37 @@ +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/features/admin/api/adminWallet.ts b/frontend/src/features/admin/api/adminWallet.ts new file mode 100644 index 0000000..ff02d08 --- /dev/null +++ b/frontend/src/features/admin/api/adminWallet.ts @@ -0,0 +1,36 @@ +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/features/admin/api/systemConfigs.ts b/frontend/src/features/admin/api/systemConfigs.ts new file mode 100644 index 0000000..45b6086 --- /dev/null +++ b/frontend/src/features/admin/api/systemConfigs.ts @@ -0,0 +1,22 @@ +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/features/admin/components/AdminUserDialog.vue b/frontend/src/features/admin/components/AdminUserDialog.vue new file mode 100644 index 0000000..07cddd6 --- /dev/null +++ b/frontend/src/features/admin/components/AdminUserDialog.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/features/admin/components/AssignPermissionsDialog.vue b/frontend/src/features/admin/components/AssignPermissionsDialog.vue new file mode 100644 index 0000000..400b3d7 --- /dev/null +++ b/frontend/src/features/admin/components/AssignPermissionsDialog.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/frontend/src/features/admin/components/AssignRolesDialog.vue b/frontend/src/features/admin/components/AssignRolesDialog.vue new file mode 100644 index 0000000..aaaa5d8 --- /dev/null +++ b/frontend/src/features/admin/components/AssignRolesDialog.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/frontend/src/features/admin/components/AutoWelcomeConfig.vue b/frontend/src/features/admin/components/AutoWelcomeConfig.vue new file mode 100644 index 0000000..c10a6d9 --- /dev/null +++ b/frontend/src/features/admin/components/AutoWelcomeConfig.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend/src/features/admin/components/GeneralConfigDialog.vue b/frontend/src/features/admin/components/GeneralConfigDialog.vue new file mode 100644 index 0000000..d3fc64c --- /dev/null +++ b/frontend/src/features/admin/components/GeneralConfigDialog.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/features/admin/components/HomeAnnouncementsDialog.vue b/frontend/src/features/admin/components/HomeAnnouncementsDialog.vue new file mode 100644 index 0000000..4781a6e --- /dev/null +++ b/frontend/src/features/admin/components/HomeAnnouncementsDialog.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/frontend/src/features/admin/components/HomeBannersDialog.vue b/frontend/src/features/admin/components/HomeBannersDialog.vue new file mode 100644 index 0000000..f324912 --- /dev/null +++ b/frontend/src/features/admin/components/HomeBannersDialog.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/frontend/src/features/admin/components/PublishOptionsDialog.vue b/frontend/src/features/admin/components/PublishOptionsDialog.vue new file mode 100644 index 0000000..c05bdbb --- /dev/null +++ b/frontend/src/features/admin/components/PublishOptionsDialog.vue @@ -0,0 +1,574 @@ + + + + + diff --git a/frontend/src/features/admin/components/QuickReplyDialog.vue b/frontend/src/features/admin/components/QuickReplyDialog.vue new file mode 100644 index 0000000..3ca807c --- /dev/null +++ b/frontend/src/features/admin/components/QuickReplyDialog.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/frontend/src/features/admin/components/RoleDialog.vue b/frontend/src/features/admin/components/RoleDialog.vue new file mode 100644 index 0000000..c5cad91 --- /dev/null +++ b/frontend/src/features/admin/components/RoleDialog.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontend/src/features/admin/components/SalePriceDialog.vue b/frontend/src/features/admin/components/SalePriceDialog.vue new file mode 100644 index 0000000..be63c05 --- /dev/null +++ b/frontend/src/features/admin/components/SalePriceDialog.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/frontend/src/features/admin/components/TransferDialog.vue b/frontend/src/features/admin/components/TransferDialog.vue new file mode 100644 index 0000000..5afd4ea --- /dev/null +++ b/frontend/src/features/admin/components/TransferDialog.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/frontend/src/features/admin/composables/useAdminPaginatedTable.ts b/frontend/src/features/admin/composables/useAdminPaginatedTable.ts new file mode 100644 index 0000000..62481e6 --- /dev/null +++ b/frontend/src/features/admin/composables/useAdminPaginatedTable.ts @@ -0,0 +1,52 @@ +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/features/admin/composables/useAdminTable.ts b/frontend/src/features/admin/composables/useAdminTable.ts new file mode 100644 index 0000000..6bc80e3 --- /dev/null +++ b/frontend/src/features/admin/composables/useAdminTable.ts @@ -0,0 +1,42 @@ +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/features/admin/index.ts b/frontend/src/features/admin/index.ts new file mode 100644 index 0000000..f8d37ac --- /dev/null +++ b/frontend/src/features/admin/index.ts @@ -0,0 +1,11 @@ +// Admin 模块统一导出 +export * from './api/adminAuth' +export * from './api/adminDashboard' +export * from './api/adminUsers' +export * from './api/adminMgr' +export * from './api/adminRoles' +export * from './api/adminWallet' +export * from './api/adminAudit' +export * from './api/systemConfigs' +export * from './composables/useAdminTable' +export * from './composables/useAdminPaginatedTable' diff --git a/frontend/src/features/admin/views/AdminAuditLogsView.vue b/frontend/src/features/admin/views/AdminAuditLogsView.vue new file mode 100644 index 0000000..0749ef9 --- /dev/null +++ b/frontend/src/features/admin/views/AdminAuditLogsView.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue new file mode 100644 index 0000000..ba760a4 --- /dev/null +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -0,0 +1,751 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminDashboardView.vue b/frontend/src/features/admin/views/AdminDashboardView.vue new file mode 100644 index 0000000..a5cba48 --- /dev/null +++ b/frontend/src/features/admin/views/AdminDashboardView.vue @@ -0,0 +1,494 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue new file mode 100644 index 0000000..f8b8dc2 --- /dev/null +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -0,0 +1,192 @@ + + + diff --git a/frontend/src/features/admin/views/AdminListingDetailView.vue b/frontend/src/features/admin/views/AdminListingDetailView.vue new file mode 100644 index 0000000..0145a47 --- /dev/null +++ b/frontend/src/features/admin/views/AdminListingDetailView.vue @@ -0,0 +1,178 @@ + + + diff --git a/frontend/src/features/admin/views/AdminListingReviewView.vue b/frontend/src/features/admin/views/AdminListingReviewView.vue new file mode 100644 index 0000000..a2f8096 --- /dev/null +++ b/frontend/src/features/admin/views/AdminListingReviewView.vue @@ -0,0 +1,963 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminListingsView.vue b/frontend/src/features/admin/views/AdminListingsView.vue new file mode 100644 index 0000000..920fb68 --- /dev/null +++ b/frontend/src/features/admin/views/AdminListingsView.vue @@ -0,0 +1,468 @@ + + + diff --git a/frontend/src/features/admin/views/AdminLoginView.vue b/frontend/src/features/admin/views/AdminLoginView.vue new file mode 100644 index 0000000..37cc019 --- /dev/null +++ b/frontend/src/features/admin/views/AdminLoginView.vue @@ -0,0 +1,434 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminMgrUsersView.vue b/frontend/src/features/admin/views/AdminMgrUsersView.vue new file mode 100644 index 0000000..234c24b --- /dev/null +++ b/frontend/src/features/admin/views/AdminMgrUsersView.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue new file mode 100644 index 0000000..054ac08 --- /dev/null +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -0,0 +1,202 @@ + + + diff --git a/frontend/src/features/admin/views/AdminOrdersView.vue b/frontend/src/features/admin/views/AdminOrdersView.vue new file mode 100644 index 0000000..e3834f0 --- /dev/null +++ b/frontend/src/features/admin/views/AdminOrdersView.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/features/admin/views/AdminRolesView.vue b/frontend/src/features/admin/views/AdminRolesView.vue new file mode 100644 index 0000000..4d67323 --- /dev/null +++ b/frontend/src/features/admin/views/AdminRolesView.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue new file mode 100644 index 0000000..7936428 --- /dev/null +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue new file mode 100644 index 0000000..e69a41e --- /dev/null +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -0,0 +1,114 @@ + + + diff --git a/frontend/src/features/admin/views/AdminWalletLedgerView.vue b/frontend/src/features/admin/views/AdminWalletLedgerView.vue new file mode 100644 index 0000000..ee737c4 --- /dev/null +++ b/frontend/src/features/admin/views/AdminWalletLedgerView.vue @@ -0,0 +1,164 @@ + + + diff --git a/frontend/src/features/disputes/api/disputes.ts b/frontend/src/features/disputes/api/disputes.ts new file mode 100644 index 0000000..99297f5 --- /dev/null +++ b/frontend/src/features/disputes/api/disputes.ts @@ -0,0 +1,47 @@ +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/features/disputes/index.ts b/frontend/src/features/disputes/index.ts new file mode 100644 index 0000000..c202752 --- /dev/null +++ b/frontend/src/features/disputes/index.ts @@ -0,0 +1,2 @@ +// Disputes 模块统一导出 +export * from './api/disputes' diff --git a/frontend/src/features/seller/composables/usePublishDraft.ts b/frontend/src/features/seller/composables/usePublishDraft.ts new file mode 100644 index 0000000..a71a540 --- /dev/null +++ b/frontend/src/features/seller/composables/usePublishDraft.ts @@ -0,0 +1,124 @@ +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/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts new file mode 100644 index 0000000..f18690f --- /dev/null +++ b/frontend/src/features/seller/composables/usePublishForm.ts @@ -0,0 +1,568 @@ +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/features/seller/index.ts b/frontend/src/features/seller/index.ts new file mode 100644 index 0000000..7dbba95 --- /dev/null +++ b/frontend/src/features/seller/index.ts @@ -0,0 +1,3 @@ +// Seller 模块统一导出 +export * from './composables/usePublishForm' +export * from './composables/usePublishDraft' diff --git a/frontend/src/features/seller/views/SellerEarningsView.vue b/frontend/src/features/seller/views/SellerEarningsView.vue new file mode 100644 index 0000000..c59164e --- /dev/null +++ b/frontend/src/features/seller/views/SellerEarningsView.vue @@ -0,0 +1,9 @@ + diff --git a/frontend/src/features/seller/views/SellerHandoffsView.vue b/frontend/src/features/seller/views/SellerHandoffsView.vue new file mode 100644 index 0000000..8755ca5 --- /dev/null +++ b/frontend/src/features/seller/views/SellerHandoffsView.vue @@ -0,0 +1,130 @@ + + + diff --git a/frontend/src/features/seller/views/SellerListingCreateView.vue b/frontend/src/features/seller/views/SellerListingCreateView.vue new file mode 100644 index 0000000..e2c7ed1 --- /dev/null +++ b/frontend/src/features/seller/views/SellerListingCreateView.vue @@ -0,0 +1,490 @@ + + + + + diff --git a/frontend/src/features/seller/views/SellerListingsView.vue b/frontend/src/features/seller/views/SellerListingsView.vue new file mode 100644 index 0000000..903cd26 --- /dev/null +++ b/frontend/src/features/seller/views/SellerListingsView.vue @@ -0,0 +1,86 @@ + + +