diff --git a/docs/FEATURES_ARCHITECTURE_PLAN.md b/docs/FEATURES_ARCHITECTURE_PLAN.md new file mode 100644 index 0000000..e60a6fd --- /dev/null +++ b/docs/FEATURES_ARCHITECTURE_PLAN.md @@ -0,0 +1,382 @@ +# Features 架构迁移计划 + +## 目标架构设计 + +### 目录结构 + +``` +frontend/src/ +├── features/ # 业务功能模块(按业务领域组织) +│ ├── auth/ # 认证与账户 +│ │ ├── api/ # API 调用 +│ │ ├── components/ # 该模块专属组件 +│ │ ├── composables/ # 业务逻辑 +│ │ ├── views/ # 页面视图 +│ │ ├── types.ts # 类型定义 +│ │ └── index.ts # 模块导出 +│ ├── listings/ # 商品浏览与搜索 +│ ├── orders/ # 订单管理 +│ ├── chats/ # 聊天消息 +│ ├── wallet/ # 钱包支付 +│ ├── disputes/ # 争议仲裁 +│ ├── seller/ # 卖家中心 +│ └── admin/ # 管理后台 +├── shared/ # 跨模块共享资源 +│ ├── components/ # 通用UI组件 +│ │ ├── ui/ # 基础组件 (Button, Input...) +│ │ ├── business/ # 业务组件 (ListingCard, OrderStatus...) +│ │ └── layout/ # 布局组件 +│ ├── composables/ # 通用工具函数 +│ │ ├── useDebounce.ts +│ │ ├── useLazyLoad.ts +│ │ └── useMoney.ts +│ ├── utils/ # 工具函数 +│ ├── types/ # 全局类型定义 +│ ├── styles/ # 全局样式 +│ └── api/ # API 基础设施 +│ ├── client.ts # axios 实例 +│ └── types.ts # 通用 API 类型 +├── stores/ # 全局状态管理(仅全局状态) +├── router/ # 路由配置 +├── layouts/ # 布局模板 +├── App.vue +└── main.ts +``` + +--- + +## 渐进式迁移策略 + +### 阶段 1:基础设施准备(P0) +**目标:** 创建新的目录结构,建立共享层 + +**任务:** +1. 创建 `features/` 和 `shared/` 目录 +2. 迁移共享资源到 `shared/`: + - `shared/api/` ← `api/client.ts`, `api/types.ts` + - `shared/utils/` ← `utils/` + - `shared/types/` ← `types/` + - `shared/composables/` ← 通用 composables +3. 保持原有路径的 re-export 兼容层 + +--- + +### 阶段 2:第一批核心模块迁移(P1) +**优先级排序依据:** 边界清晰 + 高复用 + 当前开发热点 + +#### 2.1 订单模块(orders)- 最高优先级 +**理由:** 当前开发重点,边界清晰,依赖关系多 + +**迁移内容:** +``` +features/orders/ +├── api/ +│ └── orders.ts ← api/orders.ts +├── components/ +│ ├── OrderCard.vue ← views/account/components/ +│ ├── OrderStatusBadge.vue +│ └── PaymentQRCode.vue +├── composables/ +│ ├── useOrderDetail.ts ← composables/order/useOrderDetail.ts(需拆分) +│ ├── useOrderSnapshot.ts ← composables/order/useOrderSnapshot.ts +│ ├── usePaymentPolling.ts # 新:从 useOrderDetail 拆分 +│ └── useSettlement.ts # 新:从 useOrderDetail 拆分 +├── views/ +│ ├── OrdersView.vue ← views/account/OrdersView.vue +│ ├── OrderDetailView.vue ← views/account/OrderDetailView.vue +│ ├── OrderCreateView.vue ← views/account/OrderCreateView.vue +│ ├── MobileOrdersView.vue ← views/mobile/MobileOrdersView.vue +│ └── MobileOrderDetailView.vue +├── types.ts +└── index.ts +``` + +**重构点:** +- 拆分 `useOrderDetail.ts`(目前混合了订单、支付、结算、争议逻辑) +- 提取支付轮询逻辑到独立 composable +- 提取结算逻辑到独立 composable + +--- + +#### 2.2 钱包模块(wallet) +**理由:** 边界清晰,被订单依赖 + +**迁移内容:** +``` +features/wallet/ +├── api/ +│ └── wallet.ts ← api/wallet.ts +├── composables/ +│ ├── useWallet.ts # 新:封装钱包状态 +│ ├── useMoney.ts ← composables/useMoney.ts +│ └── usePricingCalculator.ts ← composables/usePricingCalculator.ts +├── views/ +│ └── WalletView.vue ← views/account/WalletView.vue +├── types.ts +└── index.ts +``` + +--- + +#### 2.3 聊天模块(chats) +**理由:** 边界清晰,独立性强 + +**迁移内容:** +``` +features/chats/ +├── api/ +│ └── chats.ts ← api/chats.ts +├── components/ +│ ├── ChatBubble.vue ← views/account/components/ +│ ├── MessageInput.vue +│ └── ChatAttachmentImage.vue ← components/ChatAttachmentImage.vue +├── composables/ +│ └── useChatSSE.ts ← composables/useChatSSE.ts +├── views/ +│ ├── ChatView.vue ← views/account/ChatView.vue +│ ├── MessagesView.vue ← views/account/MessagesView.vue +│ ├── MobileChatView.vue ← views/mobile/MobileChatView.vue +│ └── MobileMessagesView.vue +├── types.ts +└── index.ts +``` + +--- + +### 阶段 3:第二批模块迁移(P2) + +#### 3.1 商品浏览模块(listings) +``` +features/listings/ +├── api/ +│ ├── listings.ts ← api/listings.ts +│ ├── listingOptions.ts ← api/listingOptions.ts +│ └── homeConfig.ts ← api/homeConfig.ts +├── components/ +│ ├── ListingCard.vue ← views/public/components/ +│ ├── HomeFilters.vue +│ ├── RangeFilter.vue +│ └── SkinFilter.vue +├── composables/ +│ ├── useHomeFilters.ts ← composables/home/useHomeFilters.ts +│ ├── useFilterOptions.ts ← composables/home/useFilterOptions.ts +│ └── useListingQuery.ts ← composables/home/useListingQuery.ts +├── views/ +│ ├── HomeView.vue ← views/public/HomeView.vue +│ ├── ListingsView.vue ← views/public/ListingsView.vue +│ ├── ListingDetailView.vue ← views/public/ListingDetailView.vue +│ ├── MobileHomeView.vue ← views/mobile/MobileHomeView.vue +│ └── MobileListingDetailView.vue +├── types.ts +└── index.ts +``` + +#### 3.2 用户认证模块(auth) +``` +features/auth/ +├── api/ +│ ├── auth.ts ← api/auth.ts +│ ├── realname.ts ← api/realname.ts +│ └── notifications.ts ← api/notifications.ts +├── composables/ +│ ├── useSmsCountdown.ts ← composables/useSmsCountdown.ts +│ └── useAuth.ts # 新:封装认证逻辑 +├── views/ +│ ├── LoginView.vue ← views/public/LoginView.vue +│ ├── ProfileView.vue ← views/account/ProfileView.vue +│ ├── RealnameView.vue ← views/account/RealnameView.vue +│ ├── NotificationsView.vue ← views/account/NotificationsView.vue +│ └── Mobile*.vue +├── types.ts +└── index.ts +``` + +--- + +### 阶段 4:剩余模块迁移(P3) + +#### 4.1 卖家中心(seller) +``` +features/seller/ +├── api/ # 复用 listings.ts +├── composables/ +│ ├── usePublishForm.ts ← composables/usePublishForm.ts +│ └── usePublishDraft.ts ← composables/usePublishDraft.ts +├── views/ +│ ├── SellerListingsView.vue +│ ├── SellerListingCreateView.vue +│ ├── SellerHandoffsView.vue +│ └── SellerEarningsView.vue +└── index.ts +``` + +#### 4.2 争议模块(disputes) +``` +features/disputes/ +├── api/ +│ └── disputes.ts ← api/disputes.ts +├── components/ +│ └── DisputeDialog.vue # 从 OrderDetail 拆分 +├── composables/ +│ └── useDispute.ts # 从 useOrderDetail 拆分 +└── index.ts +``` + +#### 4.3 管理后台(admin) +``` +features/admin/ +├── api/ +│ ├── adminAuth.ts +│ ├── adminDashboard.ts +│ ├── adminUsers.ts +│ └── ...(其他admin API) +├── components/ +│ └── (管理端组件) +├── composables/ +│ ├── useAdminTable.ts ← composables/useAdminTable.ts +│ └── useAdminPaginatedTable.ts +├── views/ +│ └── Admin*.vue ← views/admin/ +└── index.ts +``` + +--- + +## 迁移实施步骤(单个模块) + +### Step 1: 创建目标目录结构 +```bash +mkdir -p features/{module}/api +mkdir -p features/{module}/components +mkdir -p features/{module}/composables +mkdir -p features/{module}/views +``` + +### Step 2: 移动文件 +```bash +# API +mv src/api/{module}.ts features/{module}/api/ + +# Views +mv src/views/account/{Module}*.vue features/{module}/views/ +mv src/views/mobile/Mobile{Module}*.vue features/{module}/views/ + +# Composables +mv src/composables/{module}/ features/{module}/composables/ +``` + +### Step 3: 更新导入路径 +```typescript +// 旧路径 +import { getOrders } from '@/api/orders' +import { useOrderDetail } from '@/composables/order/useOrderDetail' + +// 新路径 +import { getOrders } from '@/features/orders/api/orders' +import { useOrderDetail } from '@/features/orders/composables/useOrderDetail' + +// 或通过模块入口 +import { getOrders, useOrderDetail } from '@/features/orders' +``` + +### Step 4: 创建模块 index.ts +```typescript +// features/orders/index.ts +export * from './api/orders' +export * from './composables/useOrderDetail' +export * from './composables/useOrderSnapshot' +export type * from './types' +``` + +### Step 5: 更新路由配置 +```typescript +// router/orderRoutes.ts +import OrdersView from '@/features/orders/views/OrdersView.vue' +import OrderDetailView from '@/features/orders/views/OrderDetailView.vue' +``` + +### Step 6: 建立兼容层(可选) +在旧路径保留 re-export,逐步迁移其他模块的导入: +```typescript +// api/orders.ts(旧路径) +export * from '@/features/orders/api/orders' +``` + +### Step 7: 验证与测试 +- 运行 `npm run typecheck` 检查类型错误 +- 运行 `npm run dev` 验证运行时无误 +- 手动测试迁移模块的功能 + +--- + +## 迁移优先级总结 + +| 阶段 | 模块 | 优先级 | 预计工作量 | 依赖关系 | +|------|------|--------|-----------|---------| +| P0 | 基础设施 | 最高 | 2小时 | 无 | +| P1 | orders | 最高 | 4小时 | 依赖 wallet, chats | +| P1 | wallet | 高 | 2小时 | 无 | +| P1 | chats | 高 | 2小时 | 无 | +| P2 | listings | 中 | 3小时 | 无 | +| P2 | auth | 中 | 3小时 | 无 | +| P3 | seller | 低 | 2小时 | 依赖 listings | +| P3 | disputes | 低 | 1小时 | 依赖 orders | +| P3 | admin | 低 | 4小时 | 依赖所有模块 | + +**总计:** ~23小时工作量 + +--- + +## 迁移检查清单 + +### 每个模块完成后需要验证: +- [ ] 类型检查通过 (`npm run typecheck`) +- [ ] 开发服务器启动正常 (`npm run dev`) +- [ ] 路由访问正常 +- [ ] API 调用正常 +- [ ] 页面功能正常 +- [ ] 移动端兼容性正常 +- [ ] 无 console 错误 + +### 全部迁移完成后: +- [ ] 删除旧的 `api/`、`views/`、部分 `composables/` 目录 +- [ ] 删除兼容层 re-export +- [ ] 更新 `tsconfig.json` 路径别名(如需要) +- [ ] 更新团队文档 + +--- + +## 风险与注意事项 + +### 高风险点: +1. **循环依赖问题:** features 间互相导入可能导致循环依赖 + - **解决方案:** 将共享类型提取到 `shared/types/`,严格控制跨 feature 导入 + +2. **导入路径大量变更:** 可能引入遗漏的导入错误 + - **解决方案:** 每迁移一个模块立即运行 typecheck,使用 VS Code 的"查找所有引用" + +3. **移动端与桌面端组件复用:** 同一 feature 内可能有多个平台的 view + - **解决方案:** 在 `views/` 下按平台分组或使用文件命名区分 + +### 中风险点: +1. **路由配置分散:** 当前路由已按模块分离,需同步更新 +2. **Stores 依赖:** session/adminSession 是全局状态,保留在 `stores/` +3. **composables 拆分:** `useOrderDetail.ts` 需要拆分,可能影响现有功能 + +--- + +## 后续优化建议 + +迁移完成后可以进一步优化: + +1. **模块懒加载优化:** 利用 Vite 的动态导入,按 feature 分包 +2. **类型安全增强:** 为每个 feature 定义严格的类型边界 +3. **测试覆盖:** 为每个 feature 添加单元测试和集成测试 +4. **文档完善:** 为每个 feature 添加 README.md 说明职责和使用方法 +5. **性能监控:** 利用 `composables/performance/` 对每个 feature 进行性能监控 + +--- + +**创建时间:** 2026-06-04 +**负责人:** yml +**状态:** 待执行 diff --git a/docs/FEATURES_MIGRATION_PROGRESS.md b/docs/FEATURES_MIGRATION_PROGRESS.md new file mode 100644 index 0000000..78f3909 --- /dev/null +++ b/docs/FEATURES_MIGRATION_PROGRESS.md @@ -0,0 +1,194 @@ +# Features 架构迁移进度报告 + +**日期:** 2026-06-04 +**分支:** refactor/features-architecture +**状态:** 进行中 + +--- + +## 已完成的工作 + +### ✅ P0: 基础设施准备 +创建了新的目录结构并迁移共享资源: + +``` +frontend/src/ +├── features/ # 新:业务模块目录 +├── shared/ # 新:共享资源层 +│ ├── api/ # API 基础设施 (client.ts) +│ ├── composables/ # 通用组合函数 (useMoney, useSmsCountdown, usePricingCalculator) +│ ├── components/ # 共享组件 +│ ├── utils/ # 工具函数 (8个文件) +│ ├── types/ # 全局类型定义 +│ └── styles/ # 全局样式 (5个CSS文件) +``` + +**文件统计:** +- shared/ 目录:22个文件 +- 已建立模块化导出系统 (index.ts) + +--- + +### ✅ P1.1: 钱包模块(wallet) +完整迁移钱包模块到 `features/wallet/`: + +``` +features/wallet/ +├── api/ +│ └── wallet.ts # 钱包 API (5个函数) +├── composables/ +│ └── useWallet.ts # 新:钱包状态管理 composable +├── views/ +│ └── WalletView.vue # 用户钱包页面 +├── types.ts # 模块类型定义 +└── index.ts # 模块统一导出 +``` + +**功能:** +- 余额查询、充值、账单明细 +- 支付订单、结算金额 +- 管理员资金账本 + +**已修复:** +- ✅ 更新导入路径到 `@/shared/` +- ✅ 类型依赖正确引用 + +--- + +### ✅ P1.2: 聊天模块(chats) +完整迁移聊天模块到 `features/chats/`: + +``` +features/chats/ +├── api/ +│ └── chats.ts # 聊天 API +├── components/ +│ └── ChatAttachmentImage.vue # 聊天附件组件 +├── composables/ +│ └── useChatSSE.ts # SSE 实时连接 +├── views/ +│ ├── ChatView.vue # 桌面端聊天页 +│ ├── MessagesView.vue # 消息列表 +│ ├── MobileChatView.vue # 移动端聊天 +│ └── MobileMessagesView.vue # 移动端消息列表 +└── index.ts # 模块统一导出 +``` + +**功能:** +- 买卖双方沟通 +- 端到端聊天、附件支持 +- 实时消息推送(SSE) +- 管理员聊天转接、快速回复 + +**已修复:** +- ✅ 更新导入路径到 `@/shared/` + +--- + +## 当前存在的类型错误 + +运行 `npm run typecheck` 发现以下问题(需要在后续修复): + +### 1. 测试文件缺少依赖 +``` +src/composables/home/__tests__/*.spec.ts - 缺少 vitest +``` +**解决方案:** 安装 vitest 或暂时忽略 + +### 2. 订单模块类型错误 +``` +src/composables/order/useOrderDetail.ts - evidence 属性不存在 +src/composables/order/useOrderSnapshot.ts - listing_snapshot 属性不存在 +``` +**解决方案:** 在迁移订单模块时统一修复 + +### 3. Shared 导出问题 +- ✅ 已修复:shared/composables/index.ts 的 default 导出改为命名导出 +- ✅ 已修复:shared/api/client.ts 导入路径 + +### 4. 第三方库缺失 +``` +views/account/OrderDetailView.vue - 缺少 qrcode 类型 +``` +**解决方案:** `npm install @types/qrcode` (已在 package.json 中) + +--- + +## 下一步工作 + +### 🔄 P1.3: 订单模块(orders)- 待开始 +这是最复杂的模块,需要: + +1. **拆分 useOrderDetail.ts**(当前混合了多个领域) + - 提取支付轮询逻辑 → `usePaymentPolling.ts` + - 提取结算逻辑 → `useSettlement.ts` + - 保留核心订单逻辑 + +2. **迁移文件** + - API: `api/orders.ts` + - Views: 8个页面(桌面3个 + 移动3个 + 管理2个) + - Composables: `order/useOrderDetail.ts`, `order/useOrderSnapshot.ts` + - Components: 订单卡片、状态徽章、支付二维码等 + +3. **修复类型错误** + - 补充 Order 接口中缺失的属性 + - 修复 evidence、listing_snapshot、checkout_info、counter_info + +--- + +## 文件移动统计 + +| 模块 | 状态 | API | Views | Composables | Components | +|------|------|-----|-------|-------------|------------| +| shared | ✅ | 1 | 0 | 3 | 2 | +| wallet | ✅ | 1 | 1 | 1 | 0 | +| chats | ✅ | 1 | 4 | 1 | 1 | +| orders | 🔄 | 待迁移 | 8 | 2 | 多个 | +| listings | ⏸️ | - | - | - | - | +| auth | ⏸️ | - | - | - | - | +| seller | ⏸️ | - | - | - | - | +| disputes | ⏸️ | - | - | - | - | +| admin | ⏸️ | - | - | - | - | + +--- + +## 风险与注意事项 + +### ⚠️ 已发现的风险 +1. **订单模块复杂度高:** useOrderDetail.ts 混合了多个业务领域,需要谨慎拆分 +2. **类型错误积累:** 订单相关的类型定义不完整,需要补充 +3. **移动端路由:** 需要同步更新路由配置 + +### ✅ 已缓解的风险 +- 共享资源已成功提取到 shared/ 目录 +- 钱包和聊天模块迁移顺利,验证了迁移方案可行性 +- 建立了模块化导出体系 + +--- + +## 验证清单 + +### 每个模块完成后: +- [x] 钱包模块:文件已迁移,导入路径已修复 +- [x] 聊天模块:文件已迁移,导入路径已修复 +- [ ] 运行 `npm run typecheck` - 有既存错误,待订单模块时统一修复 +- [ ] 运行 `npm run dev` - 待验证 +- [ ] 测试页面功能 - 待验证 + +--- + +## 技术债务 + +1. **测试覆盖不足:** 缺少单元测试,依赖 vitest +2. **类型定义不完整:** Order 接口缺少多个属性 +3. **兼容层未建立:** 旧路径的 re-export 尚未创建(如需要) + +--- + +**下次继续:** 迁移订单模块,这是 P1 阶段的最后一个模块,也是最复杂的一个。 + +**预计剩余工作量:** +- P1 订单模块:4小时 +- P2 模块(listings, auth):6小时 +- P3 模块(seller, disputes, admin):7小时 +- 总计:~17小时 diff --git a/frontend/src/features/chats/api/chats.ts b/frontend/src/features/chats/api/chats.ts new file mode 100644 index 0000000..0cb9d85 --- /dev/null +++ b/frontend/src/features/chats/api/chats.ts @@ -0,0 +1,187 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse, PaginatedResult } from '@/shared/types/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/features/chats/components/ChatAttachmentImage.vue b/frontend/src/features/chats/components/ChatAttachmentImage.vue new file mode 100644 index 0000000..a8ef44f --- /dev/null +++ b/frontend/src/features/chats/components/ChatAttachmentImage.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/features/chats/composables/useChatSSE.ts b/frontend/src/features/chats/composables/useChatSSE.ts new file mode 100644 index 0000000..d93f5af --- /dev/null +++ b/frontend/src/features/chats/composables/useChatSSE.ts @@ -0,0 +1,118 @@ +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/features/chats/index.ts b/frontend/src/features/chats/index.ts new file mode 100644 index 0000000..a209268 --- /dev/null +++ b/frontend/src/features/chats/index.ts @@ -0,0 +1,3 @@ +// Chats 模块统一导出 +export * from './api/chats' +export * from './composables/useChatSSE' diff --git a/frontend/src/features/chats/views/ChatView.vue b/frontend/src/features/chats/views/ChatView.vue new file mode 100644 index 0000000..0e1f4d8 --- /dev/null +++ b/frontend/src/features/chats/views/ChatView.vue @@ -0,0 +1,540 @@ + + + + + + diff --git a/frontend/src/features/chats/views/MessagesView.vue b/frontend/src/features/chats/views/MessagesView.vue new file mode 100644 index 0000000..3f8cb44 --- /dev/null +++ b/frontend/src/features/chats/views/MessagesView.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/frontend/src/features/chats/views/MobileChatView.vue b/frontend/src/features/chats/views/MobileChatView.vue new file mode 100644 index 0000000..2543676 --- /dev/null +++ b/frontend/src/features/chats/views/MobileChatView.vue @@ -0,0 +1,517 @@ + + + + + diff --git a/frontend/src/features/chats/views/MobileMessagesView.vue b/frontend/src/features/chats/views/MobileMessagesView.vue new file mode 100644 index 0000000..53f8e62 --- /dev/null +++ b/frontend/src/features/chats/views/MobileMessagesView.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/frontend/src/features/wallet/api/wallet.ts b/frontend/src/features/wallet/api/wallet.ts new file mode 100644 index 0000000..a89ddeb --- /dev/null +++ b/frontend/src/features/wallet/api/wallet.ts @@ -0,0 +1,54 @@ +import { apiClient } from '@/shared/api/client' + +import type { ApiResponse, PaginatedResult } from '@/shared/types/types' +import type { BalanceType, LedgerDirection, WalletStatus } from '@/shared/types/status' +import type { PaymentOrder } from '@/api/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/features/wallet/composables/useWallet.ts b/frontend/src/features/wallet/composables/useWallet.ts new file mode 100644 index 0000000..d45637a --- /dev/null +++ b/frontend/src/features/wallet/composables/useWallet.ts @@ -0,0 +1,54 @@ +import { ref, computed } from 'vue' +import { fetchWalletBalance, fetchWalletLedger } from '../api/wallet' +import type { WalletAccount, WalletLedger } from '../api/wallet' + +export function useWallet() { + const balance = ref(null) + const ledgers = ref([]) + const loading = ref(false) + const error = ref(null) + + const availableBalance = computed(() => balance.value?.available_balance ?? 0) + const frozenBalance = computed(() => balance.value?.frozen_balance ?? 0) + const totalBalance = computed(() => availableBalance.value + frozenBalance.value) + + async function loadBalance() { + loading.value = true + error.value = null + try { + balance.value = await fetchWalletBalance() + } catch (err) { + error.value = err instanceof Error ? err.message : '加载余额失败' + throw err + } finally { + loading.value = false + } + } + + async function loadLedger(page = 1, pageSize = 20) { + loading.value = true + error.value = null + try { + const result = await fetchWalletLedger(page, pageSize) + ledgers.value = result.items + return result + } catch (err) { + error.value = err instanceof Error ? err.message : '加载账单失败' + throw err + } finally { + loading.value = false + } + } + + return { + balance, + ledgers, + loading, + error, + availableBalance, + frozenBalance, + totalBalance, + loadBalance, + loadLedger, + } +} diff --git a/frontend/src/features/wallet/index.ts b/frontend/src/features/wallet/index.ts new file mode 100644 index 0000000..c6d2541 --- /dev/null +++ b/frontend/src/features/wallet/index.ts @@ -0,0 +1,4 @@ +// Wallet 模块统一导出 +export * from './api/wallet' +export * from './composables/useWallet' +export type * from './types' diff --git a/frontend/src/features/wallet/types.ts b/frontend/src/features/wallet/types.ts new file mode 100644 index 0000000..6c322de --- /dev/null +++ b/frontend/src/features/wallet/types.ts @@ -0,0 +1,18 @@ +// Wallet 模块类型定义 +export interface WalletBalance { + balance: number + frozenBalance: number +} + +export interface WalletTransaction { + id: number + type: string + amount: number + balance: number + description: string + createdAt: string +} + +export interface RechargeRequest { + amount: number +} diff --git a/frontend/src/features/wallet/views/WalletView.vue b/frontend/src/features/wallet/views/WalletView.vue new file mode 100644 index 0000000..ef3421b --- /dev/null +++ b/frontend/src/features/wallet/views/WalletView.vue @@ -0,0 +1,691 @@ + + + + + diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts new file mode 100644 index 0000000..b64479e --- /dev/null +++ b/frontend/src/shared/api/client.ts @@ -0,0 +1,172 @@ +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 '@/shared/types/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/shared/api/index.ts b/frontend/src/shared/api/index.ts new file mode 100644 index 0000000..42e7536 --- /dev/null +++ b/frontend/src/shared/api/index.ts @@ -0,0 +1,2 @@ +// API 基础设施 +export * from './client' diff --git a/frontend/src/shared/components/business/ChatAttachmentImage.vue b/frontend/src/shared/components/business/ChatAttachmentImage.vue new file mode 100644 index 0000000..a8ef44f --- /dev/null +++ b/frontend/src/shared/components/business/ChatAttachmentImage.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/shared/components/layout/MobileBottomNav.vue b/frontend/src/shared/components/layout/MobileBottomNav.vue new file mode 100644 index 0000000..47d9f44 --- /dev/null +++ b/frontend/src/shared/components/layout/MobileBottomNav.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend/src/shared/composables/index.ts b/frontend/src/shared/composables/index.ts new file mode 100644 index 0000000..2026db7 --- /dev/null +++ b/frontend/src/shared/composables/index.ts @@ -0,0 +1,4 @@ +// 通用 Composables +export { useMoney } from './useMoney' +export { useSmsCountdown } from './useSmsCountdown' +export { usePricingCalculator } from './usePricingCalculator' diff --git a/frontend/src/shared/composables/useMoney.ts b/frontend/src/shared/composables/useMoney.ts new file mode 100644 index 0000000..da286a5 --- /dev/null +++ b/frontend/src/shared/composables/useMoney.ts @@ -0,0 +1,3 @@ +export function useMoney() { + return (value: number | undefined | null) => `¥${Math.round(Number(value || 0))}` +} diff --git a/frontend/src/shared/composables/usePricingCalculator.ts b/frontend/src/shared/composables/usePricingCalculator.ts new file mode 100644 index 0000000..caa3230 --- /dev/null +++ b/frontend/src/shared/composables/usePricingCalculator.ts @@ -0,0 +1,195 @@ +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/shared/composables/useSmsCountdown.ts b/frontend/src/shared/composables/useSmsCountdown.ts new file mode 100644 index 0000000..0aaed28 --- /dev/null +++ b/frontend/src/shared/composables/useSmsCountdown.ts @@ -0,0 +1,69 @@ +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/shared/index.ts b/frontend/src/shared/index.ts new file mode 100644 index 0000000..7dfe653 --- /dev/null +++ b/frontend/src/shared/index.ts @@ -0,0 +1,5 @@ +// 共享资源导出 +export * from './api' +export * from './composables' +export * from './utils' +export * as types from './types' diff --git a/frontend/src/shared/styles/admin.css b/frontend/src/shared/styles/admin.css new file mode 100644 index 0000000..a5f3834 --- /dev/null +++ b/frontend/src/shared/styles/admin.css @@ -0,0 +1,1254 @@ +/* ========== Admin Shell Layout ========== */ +.admin-shell { + display: grid; + min-height: 100vh; + grid-template-columns: 220px 1fr; + background: #f0f2f5; + transition: grid-template-columns 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.admin-shell--collapsed { + grid-template-columns: 64px 1fr; +} + +/* ========== Sidebar ========== */ +.admin-sidebar { + display: flex; + flex-direction: column; + min-width: 0; + background: linear-gradient(180deg, #1a1f36 0%, #101428 100%); + padding: 0; + color: #a3aed0; + overflow: hidden; + position: sticky; + top: 0; + height: 100vh; +} + +.admin-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 60px; + padding: 0 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.admin-brand { + display: flex; + align-items: center; + gap: 12px; + color: #ffffff; + font-weight: 700; + text-decoration: none; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, #4f7cff, #6c5ce7); + color: #ffffff; + font-size: 14px; + font-weight: 900; + flex-shrink: 0; +} + +.brand-text { + font-size: 15px; + font-weight: 800; + letter-spacing: 0.5px; + transition: opacity 0.2s, width 0.3s; + overflow: hidden; + white-space: nowrap; +} + +.admin-shell--collapsed .brand-text { + opacity: 0; + width: 0; +} + +.collapse-btn { + cursor: pointer; + color: #a3aed0; + font-size: 18px; + padding: 6px; + border-radius: 6px; + transition: all 0.2s; + flex-shrink: 0; +} + +.collapse-btn:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.08); +} + +/* ========== Navigation ========== */ +.admin-nav { + display: flex; + flex-direction: column; + gap: 2px; + padding: 16px 8px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; +} + +.admin-nav::-webkit-scrollbar { + width: 4px; +} + +.admin-nav::-webkit-scrollbar-track { + background: transparent; +} + +.admin-nav::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.admin-nav::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +.admin-nav-link { + display: flex; + align-items: center; + gap: 10px; + height: 36px; + border-radius: 8px; + padding: 0 12px; + color: #a3aed0; + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + position: relative; +} + +.admin-nav-link:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.06); +} + +.admin-nav-link.router-link-active { + color: #ffffff; + background: linear-gradient(135deg, rgba(79, 124, 255, 0.2), rgba(108, 92, 231, 0.15)); + font-weight: 600; +} + +.admin-nav-link.router-link-active::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 18px; + border-radius: 0 3px 3px 0; + background: linear-gradient(180deg, #4f7cff, #6c5ce7); +} + +.admin-nav-link .el-icon { + font-size: 15px; + flex-shrink: 0; +} + +.admin-shell--collapsed .admin-nav-link { + justify-content: center; + padding: 0; +} + +.admin-shell--collapsed .admin-nav-link::before { + left: 0; +} + +.nav-text { + transition: opacity 0.2s, width 0.3s; + overflow: hidden; + white-space: nowrap; +} + +.admin-shell--collapsed .nav-text { + opacity: 0; + width: 0; +} + +/* ========== Workspace ========== */ +.admin-workspace { + min-width: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.admin-topbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 72px; + background: #ffffff; + padding: 0 32px; + border-bottom: 1px solid #e8ecf1; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.admin-topbar > div:first-child { + display: flex; + align-items: baseline; + gap: 12px; +} + +.admin-topbar span { + color: #8f9bba; + font-size: 13px; + font-weight: 500; +} + +.admin-topbar strong { + color: #1b2559; + font-size: 15px; + font-weight: 700; +} + +.admin-topbar .el-button { + border-radius: 8px; + font-weight: 600; +} + +.admin-main { + flex: 1; + width: 100%; + min-width: 0; + padding: 28px clamp(20px, 3vw, 40px); + overflow-y: auto; + overflow-x: hidden; +} + +.admin-main .page { + max-width: none; +} + +/* ========== Login Page ========== */ +.admin-login-shell { + display: grid; + min-height: 100vh; + place-items: center; + background: linear-gradient(135deg, #0f0c29 0%, #1a1a3e 50%, #24243e 100%); + padding: 24px; + position: relative; + overflow: hidden; +} + +.admin-login-shell::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(79, 124, 255, 0.1) 0%, transparent 50%); + animation: loginPulse 15s ease-in-out infinite; +} + +@keyframes loginPulse { + 0%, 100% { transform: translate(0, 0); } + 50% { transform: translate(5%, 5%); } +} + +.admin-login-panel { + width: min(420px, 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(26, 31, 54, 0.85); + backdrop-filter: blur(20px); + padding: 36px; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.3); + position: relative; + z-index: 1; +} + +.admin-login-brand { + display: inline-flex; + align-items: center; + gap: 12px; + color: #ffffff; + font-weight: 700; +} + +.admin-login-header { + margin: 32px 0 28px; +} + +.admin-login-header h1 { + font-size: 28px; + color: #ffffff; + font-weight: 800; +} + +.admin-login-header p:last-child { + margin: 12px 0 0; + color: #a3aed0; + font-size: 14px; +} + +.admin-captcha-row { + display: grid; + grid-template-columns: 1fr 132px; + gap: 12px; + width: 100%; +} + +.captcha-image-button { + display: flex; + align-items: center; + justify-content: center; + height: 44px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + padding: 0; + cursor: pointer; + overflow: hidden; + transition: all 0.2s; +} + +.captcha-image-button:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.15); +} + +.captcha-image-button:disabled { + cursor: wait; + opacity: 0.6; +} + +.captcha-image-button img { + display: block; + width: 132px; + height: 44px; +} + +/* ========== Page Components ========== */ +.page-header { + max-width: 720px; +} + +.eyebrow { + margin: 0 0 8px; + color: #4f7cff; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; +} + +h1 { + margin: 0; + color: #1b2559; + font-size: 28px; + font-weight: 800; + line-height: 1.2; +} + +.page-header p:last-child { + margin: 12px 0 0; + color: #8f9bba; + line-height: 1.7; + font-size: 14px; +} + +.page-header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + max-width: 100%; + margin-bottom: 28px; +} + +.toolbar-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +/* ========== Metric Cards ========== */ +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 28px; +} + +.metric-card { + border: none; + border-radius: 14px; + background: #ffffff; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + transition: transform 0.2s, box-shadow 0.2s; +} + +.metric-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); +} + +.metric-card span { + display: block; + color: #8f9bba; + font-size: 13px; + font-weight: 500; +} + +.metric-card strong { + display: block; + margin-top: 10px; + color: #1b2559; + font-size: 24px; + font-weight: 800; +} + +.dashboard-metrics { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.admin-listings-page { + display: flex; + flex-direction: column; + min-height: calc(100vh - 128px); +} + +.listing-control-panel { + display: grid; + grid-template-columns: minmax(340px, 0.65fr) minmax(520px, 1fr) auto; + align-items: end; + gap: 14px; + margin-bottom: 14px; + border-radius: 14px; + background: #ffffff; + padding: 14px 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.listing-metric-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.listing-metric-strip span { + display: inline-flex; + align-items: baseline; + gap: 6px; + min-height: 34px; + border-radius: 8px; + background: #f8f9fe; + padding: 8px 10px; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.listing-metric-strip strong { + color: #1b2559; + font-size: 16px; + font-weight: 800; +} + +.listing-filter-bar { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(160px, 0.8fr) minmax(170px, 0.8fr); + gap: 12px; +} + +.listing-filter-bar .el-form-item { + margin-bottom: 0; +} + +.listing-filter-bar .el-form-item__label { + color: #8f9bba; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.2px; +} + +.listing-action-strip { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-bottom: 1px; + white-space: nowrap; +} + +/* ========== Dashboard Panels ========== */ +.dashboard-panels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; + margin-bottom: 28px; +} + +.dashboard-panel { + max-width: none; + border: none; + border-radius: 14px; + background: #ffffff; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.dashboard-panel h2 { + margin: 0 0 18px; + color: #1b2559; + font-size: 17px; + font-weight: 700; +} + +.pending-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 48px; + border-top: 1px solid #f0f2f5; + color: #8f9bba; + text-decoration: none; + padding: 0 4px; + transition: background 0.2s; + border-radius: 8px; +} + +.pending-row:first-of-type { + border-top: 0; +} + +.pending-row:hover { + background: #f8f9fe; +} + +.pending-row strong { + color: #1b2559; + font-weight: 700; +} + +/* ========== Table Panel ========== */ +.table-panel { + margin-top: 0; + width: 100%; + overflow-x: hidden; + background: #ffffff; + border-radius: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + padding: 4px; +} + +.table-panel .el-table { + --el-table-border-color: #f0f2f5; + --el-table-header-bg-color: #f8f9fe; + --el-table-header-text-color: #8f9bba; + --el-table-text-color: #1b2559; + font-size: 14px; +} + +.table-panel .el-table th { + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.table-panel .el-table__inner-wrapper { + min-width: 760px; +} + +.admin-listings-table.el-table .el-table__inner-wrapper { + min-width: 0; +} + +.admin-listings-table.el-table .el-scrollbar__wrap { + overflow-x: hidden !important; +} + +.admin-listings-table.el-table .el-scrollbar__bar.is-horizontal { + display: none; +} + +.admin-listings-table.el-table { + font-size: 12px; +} + +.admin-listings-table.el-table th { + font-size: 11px; + letter-spacing: 0; + text-transform: none; +} + +.admin-listings-table.el-table .cell { + padding: 0 5px; + line-height: 1.35; + white-space: normal; + word-break: break-word; +} + +.admin-listings-table.el-table th .cell { + white-space: nowrap; + word-break: keep-all; + overflow-wrap: normal; + overflow: visible; +} + +.admin-listings-table .listing-code { + display: inline-block; + font-size: 11px; + line-height: 1; + white-space: nowrap; + word-break: keep-all; +} + +.admin-listings-table.el-table .el-table__cell { + padding: 8px 0; +} + +.admin-listings-table.el-table .el-button--small { + padding: 4px 8px; +} + +.table-subtext { + display: block; + margin-top: 4px; + color: #8f9bba; + font-size: 12px; +} + +.table-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: none; + background: #ffffff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.table-pagination { + justify-content: flex-end; + margin-top: 12px; + border-radius: 12px; + padding: 12px 16px; + box-sizing: border-box; + width: 100%; + color: #66728f; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + white-space: nowrap; +} + +.pagination-summary, +.pagination-page, +.pagination-size-label { + flex-shrink: 0; + white-space: nowrap; +} + +.page-size-select { + width: 86px; + flex: 0 0 86px; +} + +.page-size-select .el-select__wrapper { + min-height: 30px; + padding: 4px 8px; +} + +.page-size-select .el-select__selected-item { + min-width: max-content; +} + +.admin-listings-page .table-pagination { + margin-top: auto; +} + +.pagination-controls .el-button--small { + min-width: 44px; + padding: 5px 8px; +} + +.pagination-controls .el-button + .el-button { + margin-left: 0; +} + +@media (max-width: 980px) { + .listing-control-panel { + grid-template-columns: 1fr; + align-items: stretch; + } + + .listing-filter-bar { + grid-template-columns: 1fr; + } + + .listing-action-strip { + justify-content: flex-start; + flex-wrap: wrap; + } +} + +/* ========== Filter Panel ========== */ +.filter-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; + border: none; + border-radius: 14px; + background: #ffffff; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.filter-panel .el-form-item { + margin-bottom: 0; +} + +.filter-panel .el-form-item__label { + color: #8f9bba; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ========== Order Panel ========== */ +.order-panel { + max-width: min(100%, 560px); + border: none; + border-radius: 14px; + background: #ffffff; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.order-panel p { + margin: 0 0 14px; + color: #8f9bba; +} + +.order-panel h2 { + margin: 0 0 18px; + color: #1b2559; + font-size: 18px; + font-weight: 700; +} + +/* ========== Status Panel ========== */ +.status-panel { + max-width: 520px; + border: none; + border-radius: 14px; + background: #ffffff; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.status-panel span { + color: #8f9bba; + font-size: 13px; +} + +.status-panel strong { + display: block; + margin-top: 8px; + color: #4f7cff; + font-size: 24px; + font-weight: 800; +} + +.status-panel p { + margin: 10px 0 0; + color: #8f9bba; +} + +/* ========== Forms ========== */ +.listing-form, +.login-form { + max-width: 860px; + border: none; + border-radius: 14px; + background: #ffffff; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 20px; +} + +.full-control { + width: 100%; +} + +/* ========== Dialogs ========== */ +.dialog-body { + display: grid; + gap: 14px; +} + +.dialog-body p { + margin: 0; + color: #8f9bba; + line-height: 1.6; +} + +/* ========== Pagination ========== */ +.pagination-wrap { + display: flex; + justify-content: center; + margin-top: 24px; + padding: 16px 0; +} + +/* ========== Publish Config Panel ========== */ +.publish-config-panel { + display: grid; + gap: 20px; + margin-bottom: 24px; + padding: 24px; + border: none; + border-radius: 14px; + background: #ffffff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.publish-config-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.panel-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.publish-config-main h2 { + margin: 4px 0 8px; + color: #1b2559; + font-size: 20px; + font-weight: 700; +} + +.publish-config-main span { + color: #8f9bba; + font-size: 14px; +} + +.publish-stat-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.home-stat-grid .publish-stat strong { + font-size: 18px; +} + +.publish-stat { + display: grid; + gap: 6px; + padding: 16px; + border-radius: 12px; + background: #f8f9fe; +} + +.publish-stat strong { + color: #4f7cff; + font-size: 22px; + line-height: 1; + font-weight: 800; +} + +.publish-stat span, +.config-value { + color: #8f9bba; + font-size: 13px; +} + +/* ========== Timeline ========== */ +.timeline-item { + border-top: 1px solid #f0f2f5; + padding: 16px 0; +} + +.timeline-item:first-of-type { + border-top: 0; +} + +.timeline-item strong { + display: block; + color: #1b2559; +} + +.timeline-item span { + color: #8f9bba; + font-size: 13px; +} + +.panel-action { + margin-top: 16px; +} + +/* ========== Upload Components ========== */ +.upload-line input { + width: 100%; +} + +.upload-stack { + display: grid; + gap: 12px; + width: 100%; +} + +.upload-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + width: 100%; +} + +.evidence-list { + display: grid; + gap: 10px; +} + +.evidence-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +.evidence-row span { + overflow-wrap: anywhere; + color: #8f9bba; +} + +/* ========== Code Panel ========== */ +.code-panel { + max-width: none; + border: none; + border-radius: 14px; + background: #ffffff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + overflow: hidden; +} + +.code-panel pre { + overflow-x: auto; + margin: 0; + background: #1a1f36; + color: #e2e8f0; + padding: 20px; + line-height: 1.7; + font-size: 13px; +} + +/* ========== Notification List ========== */ +.notification-list { + display: grid; + gap: 14px; +} + +.notification-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + border: none; + border-radius: 14px; + background: #ffffff; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + transition: transform 0.2s, box-shadow 0.2s; +} + +.notification-item:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); +} + +.notification-item.unread { + border-left: 3px solid #4f7cff; +} + +.notification-item span { + color: #4f7cff; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.notification-item h2 { + margin: 8px 0; + color: #1b2559; + font-size: 16px; + font-weight: 700; +} + +.notification-item p { + margin: 0 0 10px; + color: #8f9bba; + font-size: 14px; +} + +.notification-item small { + color: #a3aed0; + font-size: 12px; +} + +.notification-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +/* ========== Listing Grid ========== */ +.listing-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.listing-card { + display: block; + min-height: 172px; + border: none; + border-radius: 14px; + background: #ffffff; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + text-decoration: none; + transition: transform 0.2s, box-shadow 0.2s; +} + +.listing-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); +} + +.listing-card span { + color: #4f7cff; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.listing-card strong { + display: block; + margin-top: 12px; + color: #1b2559; + font-size: 18px; + font-weight: 700; +} + +.listing-card p { + color: #8f9bba; + line-height: 1.6; + font-size: 14px; +} + +.listing-price { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 20px; +} + +.listing-price b { + color: #1b2559; + font-weight: 700; +} + +.listing-price em { + color: #8f9bba; + font-style: normal; + font-size: 13px; +} + +/* ========== Detail Grid ========== */ +.detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +/* ========== Responsive ========== */ +@media (max-width: 1180px) { + .admin-shell { + grid-template-columns: 200px 1fr; + } + + .admin-shell--collapsed { + grid-template-columns: 64px 1fr; + } + + .admin-sidebar-header { + padding: 0 14px; + } + + .admin-nav { + padding: 10px 8px; + } + + .admin-nav-link { + padding: 0 10px; + } +} + +@media (max-width: 900px) { + .admin-shell { + grid-template-columns: 1fr; + } + + .admin-shell--collapsed { + grid-template-columns: 1fr; + } + + .admin-sidebar { + position: sticky; + top: 0; + z-index: 10; + flex-direction: row; + align-items: center; + height: auto; + min-height: 60px; + padding: 0 16px; + } + + .admin-sidebar-header { + min-height: auto; + padding: 0; + border-bottom: 0; + } + + .admin-nav { + grid-auto-flow: column; + grid-auto-columns: max-content; + gap: 8px; + padding: 0; + margin-left: auto; + overflow-x: auto; + } + + .admin-nav-link { + min-height: 40px; + white-space: nowrap; + font-size: 13px; + } + + .admin-nav-link::before { + display: none; + } + + .admin-shell--collapsed .admin-nav-link { + justify-content: flex-start; + padding: 0 12px; + } + + .admin-shell--collapsed .brand-text, + .admin-shell--collapsed .nav-text { + opacity: 1; + width: auto; + } + + .admin-topbar { + min-height: 64px; + padding: 0 20px; + } + + .admin-main { + padding: 20px 16px; + } + + .metric-grid, + .dashboard-metrics, + .dashboard-panels { + grid-template-columns: 1fr; + } + + .publish-stat-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .admin-topbar { + align-items: flex-start; + flex-direction: column; + justify-content: center; + gap: 12px; + padding: 16px; + } + + .admin-topbar .el-button { + width: 100%; + } + + .page-header-row { + flex-direction: column; + gap: 16px; + } + + .toolbar-actions { + width: 100%; + justify-content: flex-start; + } + + .admin-captcha-row { + grid-template-columns: 1fr; + } + + .captcha-image-button, + .captcha-image-button img { + width: 100%; + } + + .publish-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (prefers-reduced-motion: reduce) { + .admin-shell, + .admin-nav-link, + .metric-card, + .notification-item, + .listing-card { + transition: none; + } +} diff --git a/frontend/src/shared/styles/base.css b/frontend/src/shared/styles/base.css new file mode 100644 index 0000000..9dbf378 --- /dev/null +++ b/frontend/src/shared/styles/base.css @@ -0,0 +1,42 @@ +:root { + color: #1f2933; + background: #f6f8fb; + font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, + BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + max-width: 100%; + overflow-x: clip; +} + +#app { + max-width: 100%; + overflow-x: clip; +} + +a { + color: inherit; + text-decoration: none; +} + +/* ── Vant Toast 保护规则 ────────────────────────────────── + .van-popup 声明 background: var(--van-popup-background) = #fff (白色) + .van-toast 声明 background: var(--van-toast-background) = rgba(0,0,0,.7) (黑色) + 两个类同优先级,但 .van-popup 在 vant/lib/index.css 中后声明 → 覆盖黑色背景 + 导致 Toast 白底白字看不见。用双类选择器提升优先级强制使用黑色背景。 + ────────────────────────────────────────────────────────── */ +.van-popup.van-toast { + background: var(--van-toast-background) !important; + color: var(--van-toast-text-color) !important; +} diff --git a/frontend/src/shared/styles/home-filters.css b/frontend/src/shared/styles/home-filters.css new file mode 100644 index 0000000..d1ba2f1 --- /dev/null +++ b/frontend/src/shared/styles/home-filters.css @@ -0,0 +1,230 @@ +/* 筛选器相关样式 */ +.horizontal-filter-card { + padding: 24px; + border: 1px solid #eef1f5; + border-radius: 16px; + background: #ffffff; + box-shadow: 0 4px 12px rgba(23, 35, 61, 0.03); +} + +.filter-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.filter-title { + display: flex; + align-items: center; + gap: 12px; +} + +.filter-title strong { + font-size: 18px; + font-weight: 800; + color: #17233d; +} + +.filter-title span { + padding: 4px 10px; + border-radius: 6px; + background: #f1f5f9; + font-size: 13px; + font-weight: 700; + color: #64748b; +} + +.filter-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.filter-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: 2px solid #e2e8f0; + border-radius: 8px; + background: #ffffff; + font-size: 13px; + font-weight: 600; + color: #334155; + cursor: pointer; + transition: all 0.2s; +} + +.filter-chip:hover { + border-color: #ff6a00; + background: #fff7ed; +} + +.filter-chip.active { + border-color: #ff6a00; + background: #fff7ed; + color: #ff6a00; +} + +.filter-chip.wide { + min-width: 140px; +} + +/* 筛选器弹窗样式 */ +:global(.home-filter-popover) { + padding: 8px !important; + border-radius: 12px !important; +} + +.filter-menu, +.range-menu { + display: flex; + flex-direction: column; + gap: 4px; +} + +.filter-menu button, +.range-menu button { + padding: 10px 14px; + border: none; + border-radius: 8px; + background: transparent; + text-align: left; + font-size: 13px; + font-weight: 600; + color: #334155; + cursor: pointer; + transition: all 0.15s; +} + +.filter-menu button:hover, +.range-menu button:hover { + background: #f1f5f9; +} + +.filter-menu button.active, +.range-menu button.active { + background: #fff7ed; + color: #ff6a00; + font-weight: 700; +} + +.range-manual { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #e2e8f0; + margin-top: 4px; +} + +.range-manual :deep(.el-input-number) { + flex: 1; +} + +.range-manual span { + color: #94a3b8; + font-weight: 700; +} + +/* 皮肤筛选器 */ +.skin-filter-menu { + display: flex; + flex-direction: column; + gap: 16px; + max-height: 400px; + overflow-y: auto; +} + +.skin-reset { + padding: 10px 14px; + border: none; + border-radius: 8px; + background: transparent; + text-align: left; + font-size: 13px; + font-weight: 600; + color: #334155; + cursor: pointer; + transition: all 0.15s; +} + +.skin-reset:hover { + background: #f1f5f9; +} + +.skin-reset.active { + background: #fff7ed; + color: #ff6a00; + font-weight: 700; +} + +.skin-filter-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.skin-filter-title { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 4px; +} + +.skin-filter-title strong { + font-size: 13px; + font-weight: 700; + color: #17233d; +} + +.skin-filter-title button { + padding: 4px 8px; + border: none; + border-radius: 6px; + background: transparent; + font-size: 11px; + font-weight: 600; + color: #64748b; + cursor: pointer; + transition: all 0.15s; +} + +.skin-filter-title button:hover { + background: #f1f5f9; +} + +.skin-filter-title button.active { + background: #fff7ed; + color: #ff6a00; +} + +.skin-filter-options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; +} + +.skin-filter-options button { + padding: 8px 12px; + border: none; + border-radius: 6px; + background: transparent; + text-align: left; + font-size: 12px; + font-weight: 600; + color: #334155; + cursor: pointer; + transition: all 0.15s; +} + +.skin-filter-options button:hover { + background: #f1f5f9; +} + +.skin-filter-options button.active { + background: #fff7ed; + color: #ff6a00; + font-weight: 700; +} diff --git a/frontend/src/shared/styles/pc.css b/frontend/src/shared/styles/pc.css new file mode 100644 index 0000000..4efda52 --- /dev/null +++ b/frontend/src/shared/styles/pc.css @@ -0,0 +1,1135 @@ +.app-shell { + display: grid; + min-height: 100vh; + grid-template-columns: 248px 1fr; +} + +.sidebar { + border-right: 1px solid #e4e7ed; + background: #ffffff; + padding: 20px 16px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + font-weight: 700; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: #0f766e; + color: #ffffff; +} + +.nav { + display: grid; + gap: 6px; + margin-top: 24px; +} + +.nav-link { + display: flex; + align-items: center; + gap: 10px; + min-height: 40px; + border-radius: 8px; + padding: 0 12px; + color: #52616f; +} + +.nav-link.router-link-active { + background: #e8f5f2; + color: #0f766e; + font-weight: 600; +} + +.main { + min-width: 0; + padding: 32px; +} + +.page { + width: 100%; + max-width: 1720px; +} + +.page-header { + max-width: 720px; +} + +.eyebrow { + margin: 0 0 8px; + color: #0f766e; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: 32px; + line-height: 1.2; +} + +.page-header p:last-child { + margin: 12px 0 0; + color: #52616f; + line-height: 1.7; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 28px; +} + +.metric-card { + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 18px; +} + +.metric-card span { + display: block; + color: #6b7785; + font-size: 13px; +} + +.metric-card strong { + display: block; + margin-top: 8px; + font-size: 20px; +} + +.dashboard-metrics { + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); +} + +.dashboard-panels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; +} + +.dashboard-panel { + max-width: none; +} + +.pending-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 40px; + border-top: 1px solid #e4e7ed; + color: #52616f; +} + +.pending-row:first-of-type { + border-top: 0; +} + +.pending-row strong { + color: #111827; +} + +.login-form { + max-width: 420px; + margin-top: 28px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 20px; +} + +.code-row { + display: grid; + grid-template-columns: 1fr 96px; + gap: 10px; + width: 100%; +} + +.notice { + max-width: 520px; + margin-top: 24px; +} + +.status-panel { + max-width: 520px; + margin-top: 24px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 18px; +} + +.status-panel span { + color: #6b7785; + font-size: 13px; +} + +.status-panel strong { + display: block; + margin-top: 6px; + color: #0f766e; + font-size: 22px; +} + +.status-panel p { + margin: 8px 0 0; + color: #52616f; +} + +.listing-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-top: 28px; +} + +.listing-card { + display: block; + min-height: 172px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 18px; +} + +.listing-card span { + color: #0f766e; + font-size: 13px; + font-weight: 700; +} + +.listing-card strong { + display: block; + margin-top: 10px; + font-size: 20px; +} + +.listing-card p { + color: #52616f; + line-height: 1.6; +} + +.listing-price { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 18px; +} + +.listing-price b { + color: #111827; +} + +.listing-price em { + color: #6b7785; + font-style: normal; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-top: 28px; +} + +.page-header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + max-width: 100%; +} + +.table-panel, +.listing-form { + margin-top: 28px; +} + +.table-panel { + width: 100%; + overflow-x: auto; +} + +.table-panel .el-table__inner-wrapper { + min-width: 760px; +} + +.listing-form { + max-width: 860px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 20px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 16px; +} + +.order-panel { + max-width: min(100%, 560px); + margin-top: 28px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 20px; +} + +.order-panel p { + margin: 0 0 12px; + color: #52616f; +} + +.order-panel h2 { + margin: 0 0 14px; + font-size: 18px; +} + +.timeline-item { + border-top: 1px solid #e4e7ed; + padding: 14px 0; +} + +.timeline-item:first-of-type { + border-top: 0; +} + +.timeline-item strong { + display: block; +} + +.timeline-item span { + color: #6b7785; + font-size: 13px; +} + +.panel-action { + margin-top: 14px; +} + +.upload-line input { + width: 100%; +} + +.upload-stack { + display: grid; + gap: 10px; + width: 100%; +} + +.upload-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + width: 100%; +} + +.evidence-list { + display: grid; + gap: 8px; +} + +.evidence-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.evidence-row span { + overflow-wrap: anywhere; + color: #52616f; +} + +.full-control { + width: 100%; +} + +.dialog-body { + display: grid; + gap: 10px; +} + +.dialog-body p { + margin: 0; + color: #52616f; + line-height: 1.6; +} + +.toolbar-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.filter-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px 16px; + margin-top: 24px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 16px; +} + +.filter-panel .el-form-item { + margin-bottom: 0; +} + +.table-subtext { + display: block; + margin-top: 4px; + color: #6b7785; + font-size: 12px; +} + +.code-panel { + max-width: none; +} + +.code-panel pre { + overflow-x: auto; + margin: 0; + border-radius: 6px; + background: #111827; + color: #dbe4ee; + padding: 14px; + line-height: 1.6; +} + +.notification-list { + display: grid; + gap: 12px; + margin-top: 28px; +} + +.notification-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 16px; +} + +.notification-item.unread { + border-color: #0f766e; +} + +.notification-item span { + color: #0f766e; + font-size: 13px; + font-weight: 700; +} + +.notification-item h2 { + margin: 6px 0; + font-size: 18px; +} + +.notification-item p { + margin: 0 0 8px; + color: #52616f; +} + +.notification-item small { + color: #6b7785; +} + +.notification-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +/* PC public marketplace */ +.pc-app-shell { + min-height: 100vh; + background: #f5f7fa; +} + +.pc-topbar { + position: sticky; + top: 0; + z-index: 20; + display: grid; + grid-template-columns: auto auto minmax(260px, 420px) auto; + align-items: center; + gap: 18px; + min-height: 66px; + border-bottom: 1px solid #e8edf3; + background: rgba(255, 255, 255, 0.94); + padding: 0 clamp(20px, 4vw, 56px); + backdrop-filter: blur(12px); +} + +.pc-brand, +.pc-nav, +.pc-user-actions, +.pc-search, +.pc-login-link, +.pc-post-link, +.pc-icon-link { + display: flex; + align-items: center; +} + +.pc-brand { + gap: 10px; + font-weight: 800; +} + +.pc-brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 12px; + background: #fff3e8; + color: #ff6a00; + font-size: 13px; + font-weight: 900; +} + +.pc-brand-text { + color: #111827; + font-size: 17px; +} + +.pc-nav { + gap: 6px; +} + +.pc-nav-link { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 40px; + border-radius: 999px; + padding: 0 14px; + color: #4b5563; + font-size: 14px; + font-weight: 600; +} + +.pc-nav-link.router-link-active, +.pc-nav-link:hover { + background: #fff3e8; + color: #ff6a00; +} + +.pc-search { + gap: 10px; + height: 40px; + border: 1px solid #edf1f6; + border-radius: 999px; + background: #f7f9fc; + padding: 0 16px; + color: #7b8798; +} + +.pc-search input { + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: #111827; + font-size: 14px; +} + +.pc-user-actions { + justify-content: flex-end; + gap: 10px; +} + +.pc-icon-link, +.pc-login-link, +.pc-post-link { + justify-content: center; + min-height: 40px; + border-radius: 999px; + font-weight: 700; +} + +.pc-icon-link { + width: 40px; + border: 1px solid #e8edf3; + background: #ffffff; + color: #4b5563; +} + +.pc-login-link { + gap: 6px; + border: 1px solid #e8edf3; + background: #ffffff; + padding: 0 14px; + color: #111827; +} + +.pc-post-link { + gap: 6px; + background: #ff6a00; + padding: 0 16px; + color: #ffffff; + box-shadow: 0 10px 22px rgba(255, 106, 0, 0.18); +} + +.pc-post-link.large { + min-height: 44px; + padding: 0 22px; +} + +.pc-main { + width: min(1720px, calc(100% - 40px)); + margin: 0 auto; + padding: 18px 0 56px; +} + +.anti-fraud-strip { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + border: 1px solid #fde68a; + border-radius: 14px; + background: linear-gradient(90deg, #fffbeb, #fef3c7); + padding: 10px 16px; + color: #92400e; + font-size: 14px; + font-weight: 600; +} + +.anti-fraud-strip.compact { + margin-bottom: 22px; +} + +.pc-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 24px; + margin-top: 18px; +} + +.pc-hero-content, +.pc-action-card, +.advanced-filter-card, +.resource-card, +.feature-card, +.pc-stat-card { + border: 1px solid rgba(229, 231, 235, 0.9); + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 18px 44px rgba(17, 24, 39, 0.08); +} + +.pc-hero-content { + min-height: 360px; + border-radius: 28px; + background: linear-gradient( + 135deg, + rgba(17, 24, 39, 0.82), + rgba(120, 53, 15, 0.72) + ), + radial-gradient(circle at 82% 20%, rgba(251, 191, 36, 0.9), transparent 24%), + #111827; + padding: 48px; + color: #ffffff; + overflow: hidden; +} + +.pc-hero-content .eyebrow { + color: #fbbf24; +} + +.pc-hero h1 { + max-width: 620px; + font-size: clamp(40px, 5vw, 64px); + letter-spacing: -2px; +} + +.pc-hero-desc { + max-width: 560px; + margin: 18px 0 0; + color: rgba(255, 255, 255, 0.78); + font-size: 18px; + line-height: 1.8; +} + +.pc-hero-actions, +.hero-tags, +.sort-toolbar, +.resource-tags, +.resource-title-line { + display: flex; + align-items: center; +} + +.pc-hero-actions { + gap: 14px; + margin-top: 32px; +} + +.primary-cta, +.secondary-cta { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 46px; + border-radius: 999px; + padding: 0 22px; + font-weight: 800; +} + +.primary-cta { + gap: 8px; + background: #fbbf24; + color: #111827; +} + +.secondary-cta { + border: 1px solid rgba(255, 255, 255, 0.28); + color: #ffffff; +} + +.hero-tags { + flex-wrap: wrap; + gap: 10px; + margin-top: 34px; +} + +.hero-tags span { + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + padding: 8px 12px; + color: #fde68a; + font-size: 13px; +} + +.pc-action-card { + border-radius: 24px; + padding: 22px; +} + +.pc-action-card h2 { + margin: 0 0 18px; + font-size: 20px; +} + +.action-row { + display: grid; + grid-template-columns: 42px 1fr; + gap: 12px; + align-items: center; + border: 1px solid #edf0f4; + border-radius: 16px; + background: #ffffff; + padding: 14px; +} + +.action-row + .action-row { + margin-top: 12px; +} + +.action-row .el-icon { + width: 42px; + height: 42px; + border-radius: 14px; + background: #fff7ed; + color: #ea580c; + font-size: 20px; +} + +.action-row strong, +.action-row span { + display: block; +} + +.action-row strong { + color: #111827; +} + +.action-row span { + margin-top: 4px; + color: #6b7280; + font-size: 13px; + line-height: 1.5; +} + +.action-row.highlight { + border-color: #fbbf24; + background: #fffbeb; +} + +.pc-stat-grid, +.pc-feature-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-top: 20px; +} + +.pc-stat-card, +.feature-card { + border-radius: 20px; + padding: 20px; +} + +.pc-stat-card span, +.feature-card span { + color: #6b7280; + font-size: 13px; +} + +.pc-stat-card strong, +.feature-card strong { + display: block; + margin-top: 8px; + color: #111827; + font-size: 24px; +} + +.pc-stat-card p { + margin: 8px 0 0; + color: #6b7280; +} + +.feature-card .el-icon { + color: #f59e0b; + font-size: 24px; +} + +.feature-card strong { + font-size: 18px; +} + +.listings-header { + align-items: center; + margin-bottom: 18px; +} + +.advanced-filter-card { + border-radius: 22px; + padding: 20px; +} + +.filter-title, +.sort-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.filter-title strong { + color: #111827; + font-size: 18px; +} + +.filter-title span { + color: #6b7280; +} + +.pc-filter-grid { + display: grid; + grid-template-columns: 1.25fr repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-top: 16px; +} + +.pc-filter-grid .el-form-item { + margin-bottom: 0; +} + +.sort-toolbar { + margin-top: 16px; +} + +.pc-resource-list { + display: grid; + gap: 14px; + margin-top: 18px; +} + +.resource-card { + display: grid; + grid-template-columns: 150px minmax(0, 1fr) 150px; + gap: 18px; + align-items: center; + border-radius: 22px; + padding: 14px; + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.resource-card:hover { + transform: translateY(-2px); + box-shadow: 0 22px 50px rgba(17, 24, 39, 0.12); +} + +.resource-cover { + display: grid; + place-items: center; + height: 108px; + border-radius: 18px; + background: linear-gradient(135deg, #111827, #f59e0b); + color: #ffffff; + font-weight: 900; + overflow: hidden; +} + +.resource-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.resource-title-line { + justify-content: space-between; + gap: 12px; +} + +.resource-title-line strong { + color: #111827; + font-size: 19px; +} + +.resource-title-line em { + border-radius: 999px; + background: #dcfce7; + padding: 4px 10px; + color: #166534; + font-size: 12px; + font-style: normal; + font-weight: 700; +} + +.resource-main p { + display: -webkit-box; + margin: 10px 0 0; + overflow: hidden; + color: #6b7280; + line-height: 1.6; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.resource-tags { + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.resource-tags span { + border-radius: 999px; + background: #f3f4f6; + padding: 5px 10px; + color: #4b5563; + font-size: 12px; +} + +.resource-price-box { + border-left: 1px solid #edf0f4; + padding-left: 18px; + text-align: right; +} + +.resource-price-box span, +.resource-price-box em, +.resource-price-box small { + display: block; + color: #6b7280; + font-style: normal; +} + +.resource-price-box strong { + display: block; + margin: 4px 0; + color: #ef4444; + font-size: 28px; +} + +.pc-detail-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 22px; +} + +.pc-detail-main, +.pc-order-card { + border: 1px solid rgba(229, 231, 235, 0.9); + border-radius: 24px; + background: #ffffff; + box-shadow: 0 18px 44px rgba(17, 24, 39, 0.08); +} + +.pc-detail-main { + overflow: hidden; +} + +.detail-cover { + display: grid; + place-items: center; + height: 300px; + background: linear-gradient(135deg, #111827, #92400e 54%, #f59e0b); + color: #ffffff; + font-size: 28px; + font-weight: 900; + letter-spacing: 1px; +} + +.detail-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-title { + padding: 28px; +} + +.pc-detail .detail-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + padding: 0 28px 28px; +} + +.pc-order-card { + position: sticky; + top: 96px; + align-self: start; + margin-top: 0; + padding: 24px; +} + +.pc-order-card h2 { + margin: 0 0 8px; + font-size: 22px; +} + +.order-safe-text { + margin: 0 0 18px; + color: #6b7280; + line-height: 1.7; +} + +.order-total-box { + border-radius: 18px; + background: #fffbeb; + margin-bottom: 16px; + padding: 16px; +} + +.order-total-box span, +.order-total-box em { + display: block; + color: #92400e; + font-style: normal; +} + +.order-total-box strong { + display: block; + margin: 4px 0; + color: #ef4444; + font-size: 30px; +} + +@media (max-width: 1180px) { + .pc-topbar { + grid-template-columns: auto 1fr auto; + } + + .pc-search { + display: none; + } + + .pc-hero, + .pc-filter-grid, + .pc-detail-layout, + .pc-detail .detail-grid { + grid-template-columns: 1fr; + } + + .pc-order-card { + position: static; + } + + .resource-card { + grid-template-columns: 120px minmax(0, 1fr); + } + + .resource-price-box { + grid-column: 2; + border-left: 0; + border-top: 1px solid #edf0f4; + padding: 12px 0 0; + text-align: left; + } +} + +@media (max-width: 760px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: sticky; + top: 0; + z-index: 10; + border-right: 0; + border-bottom: 1px solid #e4e7ed; + } + + .nav { + grid-auto-flow: column; + grid-auto-columns: max-content; + overflow-x: auto; + } + + .main { + padding: 24px 16px; + } + + .metric-grid { + grid-template-columns: 1fr; + } + + .listing-grid, + .detail-grid, + .dashboard-metrics, + .dashboard-panels, + .form-grid { + grid-template-columns: 1fr; + } + + .page-header-row { + display: grid; + } + + .toolbar-actions { + justify-content: flex-start; + } +} diff --git a/frontend/src/shared/styles/performance.css b/frontend/src/shared/styles/performance.css new file mode 100644 index 0000000..dc42702 --- /dev/null +++ b/frontend/src/shared/styles/performance.css @@ -0,0 +1,36 @@ +/* 性能优化相关样式 */ + +.lazy-image { + background: #f1f5f9; + min-height: 180px; +} + +.component-loading, +.component-error { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + color: #94a3b8; + font-size: 14px; +} + +.component-error { + color: #ef4444; +} + +/* 骨架屏动画 */ +@keyframes skeleton-loading { + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +} + +.skeleton { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200px 100%; + animation: skeleton-loading 1.5s ease-in-out infinite; +} diff --git a/frontend/src/shared/types/index.ts b/frontend/src/shared/types/index.ts new file mode 100644 index 0000000..3eff5db --- /dev/null +++ b/frontend/src/shared/types/index.ts @@ -0,0 +1,4 @@ +// 全局类型定义 +export * from './types' +export * from './status' +export * from './publish' diff --git a/frontend/src/shared/types/publish.ts b/frontend/src/shared/types/publish.ts new file mode 100644 index 0000000..721d4ed --- /dev/null +++ b/frontend/src/shared/types/publish.ts @@ -0,0 +1,44 @@ +import type { ChargeMode, QuantityKey, ScreenshotKey } from '@/api/listingOptions' + +export type PublishForm = { + server_region: string + face_owner: string + haf_coin_amount: number | '' + rank_level: string + secret_kd: string + fire_level: number | '' + daily_loss_m: number | '' + accelerated_sale_ratio: number | '' + season_insurance: string + stamina_level: string + load_level: string + login_method: string + online_start: string + online_end: string + ban_record: string + common_regions: string[] + deposit_amount: number | '' + remark: string +} + +export interface PublishDraft { + form: PublishForm + quantityValues: Record + quantityModes: Record + screenshotFiles: Record + selectedSkins: string[] +} + +export interface DepositBreakdownItem { + label: string + amount: number + count: number +} + +export interface PublishPlatformPricing { + buyerCoinBasePrice: number + buyerTotalPrice: number + buyerRatio: number + platformMarkupAmount: number + ruleType: 'fixed_markup' | 'ratio_subtract' | 'none' | string +} diff --git a/frontend/src/shared/types/status.ts b/frontend/src/shared/types/status.ts new file mode 100644 index 0000000..cd3178f --- /dev/null +++ b/frontend/src/shared/types/status.ts @@ -0,0 +1,78 @@ +export const listingStatuses = ['draft', 'published', 'rented', 'offline', 'abnormal'] as const +export type ListingStatus = (typeof listingStatuses)[number] + +export const listingReviewStatuses = ['none', 'pending', 'approved', 'rejected'] as const +export type ListingReviewStatus = (typeof listingReviewStatuses)[number] + +export const orderStatuses = [ + 'pending_confirm', + 'pending_payment', + 'pending_handoff', + 'renting', + 'overdue', + 'pending_return_confirm', + 'pending_checkout_confirm', + 'pending_checkout_accept', + 'checkout_disputing', + 'completed', + 'cancelled', + 'closed', + 'disputing', + 'abnormal', +] as const +export type OrderStatus = (typeof orderStatuses)[number] + +export const handoffStatuses = [ + 'pending_owner', + 'pending_renter_confirm', + 'received', + 'pending_owner_return_confirm', + 'pending_owner_checkout', + 'pending_renter_checkout', + 'checkout_disputed', + 'returned', + 'cancelled', + 'owner_timeout', + 'renter_confirm_timeout', + 'return_overdue', + 'owner_return_confirm_timeout', + 'owner_checkout_confirm_timeout', + 'admin_closed', + 'admin_abnormal', + 'arbitrated', +] as const +export type HandoffStatus = (typeof handoffStatuses)[number] + +export const settlementStatuses = [ + 'unsettled', + 'pending', + 'frozen', + 'settled', + 'refunded', + 'cancelled', + 'closed', + 'disputed', + 'arbitrated', +] as const +export type SettlementStatus = (typeof settlementStatuses)[number] + +export const realnameStatuses = ['unknown', 'unverified', 'pending', 'verified', 'rejected'] as const +export type RealnameStatusValue = (typeof realnameStatuses)[number] + +export const userStatuses = ['active', 'frozen', 'disabled'] as const +export type UserStatus = (typeof userStatuses)[number] + +export const riskStatuses = ['normal', 'watch', 'restricted', 'blocked'] as const +export type RiskStatus = (typeof riskStatuses)[number] + +export const disputeStatuses = ['open', 'processing', 'resolved', 'closed'] as const +export type DisputeStatus = (typeof disputeStatuses)[number] + +export const walletStatuses = ['active', 'frozen', 'disabled'] as const +export type WalletStatus = (typeof walletStatuses)[number] + +export const ledgerDirections = ['in', 'out', 'freeze', 'unfreeze'] as const +export type LedgerDirection = (typeof ledgerDirections)[number] + +export const balanceTypes = ['available', 'frozen'] as const +export type BalanceType = (typeof balanceTypes)[number] diff --git a/frontend/src/shared/types/types.ts b/frontend/src/shared/types/types.ts new file mode 100644 index 0000000..1c66d49 --- /dev/null +++ b/frontend/src/shared/types/types.ts @@ -0,0 +1,12 @@ +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/shared/utils/authStorage.ts b/frontend/src/shared/utils/authStorage.ts new file mode 100644 index 0000000..9f748fa --- /dev/null +++ b/frontend/src/shared/utils/authStorage.ts @@ -0,0 +1,54 @@ +export type AuthScope = 'user' | 'admin' + +export interface AuthTokenPair { + access_token: string + refresh_token: string +} + +const userKeys = { + accessToken: 'access_token', + refreshToken: 'refresh_token', + profile: ['user_id', 'phone', 'nickname', 'avatar_url', 'realname_status'], +} + +const adminKeys = { + accessToken: 'admin_access_token', + refreshToken: 'admin_refresh_token', + profile: ['admin_id', 'admin_username'], +} + +function keysFor(scope: AuthScope) { + return scope === 'admin' ? adminKeys : userKeys +} + +export function getAccessToken(scope: AuthScope) { + return localStorage.getItem(keysFor(scope).accessToken) || '' +} + +export function getRefreshToken(scope: AuthScope) { + return localStorage.getItem(keysFor(scope).refreshToken) || '' +} + +export function setAuthTokens(scope: AuthScope, tokens: AuthTokenPair) { + const keys = keysFor(scope) + localStorage.setItem(keys.accessToken, tokens.access_token) + localStorage.setItem(keys.refreshToken, tokens.refresh_token) + notifyAuthStorageChanged(scope) +} + +export function clearAuthStorage(scope: AuthScope) { + const keys = keysFor(scope) + localStorage.removeItem(keys.accessToken) + localStorage.removeItem(keys.refreshToken) + keys.profile.forEach((key) => localStorage.removeItem(key)) + notifyAuthStorageChanged(scope) +} + +export function getLoginPath(scope: AuthScope, currentPath: string) { + if (scope === 'admin') return '/admin/login' + return currentPath.startsWith('/m') ? '/m/login' : '/login' +} + +export function notifyAuthStorageChanged(scope: AuthScope) { + window.dispatchEvent(new CustomEvent('auth-storage-changed', { detail: { scope } })) +} diff --git a/frontend/src/shared/utils/imageUpload.ts b/frontend/src/shared/utils/imageUpload.ts new file mode 100644 index 0000000..e9ada14 --- /dev/null +++ b/frontend/src/shared/utils/imageUpload.ts @@ -0,0 +1,84 @@ +const IMAGE_UPLOAD_MAX_SIDE: Record = { + avatar: 512, + chat: 1280, + "home-banner": 1920, + listing: 1920, + dispute: 1920, + handoff: 1920, + realname: 1920, +}; + +const IMAGE_UPLOAD_QUALITY: Record = { + avatar: 0.82, + chat: 0.8, + "home-banner": 0.84, + listing: 0.84, + dispute: 0.86, + handoff: 0.86, + realname: 0.86, +}; + +export async function optimizeImageForUpload(file: File, scene: string) { + if (!file.type.startsWith("image/")) return file; + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) return file; + if (typeof document === "undefined") return file; + + try { + const image = await loadImage(file); + const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] || 1600; + const quality = IMAGE_UPLOAD_QUALITY[scene] || 0.82; + const { width, height } = fitSize(image.naturalWidth, image.naturalHeight, maxSide); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return file; + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + const blob = await canvasToBlob(canvas, "image/webp", quality); + if (!blob || blob.size >= file.size) return file; + return new File([blob], replaceFileExt(file.name, "webp"), { + type: "image/webp", + lastModified: Date.now(), + }); + } catch { + return file; + } +} + +function loadImage(file: File) { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("图片读取失败")); + }; + image.src = url; + }); +} + +function fitSize(width: number, height: number, maxSide: number) { + if (width <= 0 || height <= 0) return { width: 1, height: 1 }; + const scale = Math.min(1, maxSide / Math.max(width, height)); + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +} + +function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) { + return new Promise((resolve) => { + canvas.toBlob(resolve, type, quality); + }); +} + +function replaceFileExt(filename: string, ext: string) { + const base = filename.replace(/\.[^.]+$/, ""); + return `${base || "image"}.${ext}`; +} diff --git a/frontend/src/shared/utils/index.ts b/frontend/src/shared/utils/index.ts new file mode 100644 index 0000000..13772f1 --- /dev/null +++ b/frontend/src/shared/utils/index.ts @@ -0,0 +1,9 @@ +// 通用工具函数 +export * from './authStorage' +export * from './imageUpload' +export * from './json' +export * from './listingDisplay' +export * from './pricing' +export * from './statusLabels' +export * from './systemConfigOptions' +export * from './time' diff --git a/frontend/src/shared/utils/json.ts b/frontend/src/shared/utils/json.ts new file mode 100644 index 0000000..5be683f --- /dev/null +++ b/frontend/src/shared/utils/json.ts @@ -0,0 +1,8 @@ +export function safeParseJSON(raw: string, fallback: T): T { + if (!raw || !raw.trim()) return fallback + try { + return JSON.parse(raw) as T + } catch { + return fallback + } +} diff --git a/frontend/src/shared/utils/listingDisplay.ts b/frontend/src/shared/utils/listingDisplay.ts new file mode 100644 index 0000000..3403b49 --- /dev/null +++ b/frontend/src/shared/utils/listingDisplay.ts @@ -0,0 +1,306 @@ +import type { Listing } from "@/api/listings"; + +export interface ListingDisplayChip { + label: string; + value: string; +} + +export interface ListingDisplayResource { + key: string; + label: string; + price: string; + quantity: number; + mode: string; + amount: number; +} + +export function getCoinWan(item: Listing) { + return Math.round(Number(item.haf_coin_amount || 0) / 10000); +} + +export function getCoinM(item: Listing) { + return getCoinWan(item) / 100; +} + +export function formatListingCode(item: Listing) { + return `SP${String(item.id).padStart(6, "0")}`; +} + +export function formatHafCoinM(amountWan: number) { + const amountM = amountWan / 100; + const rounded = Math.round(amountM * 10) / 10; + return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M`; +} + +export function getListingDisplayPrice(item: Listing) { + return Number(item.price || 0); +} + +export function getListingRentPrice(item: Listing) { + const buyerCoinBasePrice = readPriceBreakdownNumber(item, "buyer_coin_base_price"); + if (buyerCoinBasePrice > 0) return Math.round(buyerCoinBasePrice); + return Math.max(0, Math.round(getListingDisplayPrice(item) - getListingConsumablePrice(item))); +} + +export function getListingConsumablePrice(item: Listing) { + const consumablePrice = readPriceBreakdownNumber(item, "consumable_price"); + if (consumablePrice > 0) return Math.round(consumablePrice); + return getListingResources(item).reduce((sum, resource) => sum + resource.amount, 0); +} + +export function getListingSellerPrice(item: Listing) { + const priceBreakdown = item.asset_summary?.price_breakdown; + if (typeof priceBreakdown === "object" && priceBreakdown !== null) { + const price = readUnknownNumber((priceBreakdown as Record).seller_total_price); + if (price > 0) return price; + } + return getListingDisplayPrice(item); +} + +export function getRatioValue(item: Listing) { + const ratio = readAssetNumber(item, "publish_ratio"); + if (ratio > 0) return ratio; + const price = getListingDisplayPrice(item); + if (price <= 0) return 0; + return getCoinWan(item) / price; +} + +export function formatRatio(item: Listing) { + const ratio = getRatioValue(item); + return ratio > 0 ? `1:${formatRatioNumber(ratio)}` : "--"; +} + +export function getValuePerYuanText(item: Listing) { + return formatRatio(item); +} + +export function getLoginMethod(item: Listing) { + return item.login_platform.trim(); +} + +export function getServerRegion(item: Listing) { + return item.server_region.trim(); +} + +export function getListingTitle(item: Listing) { + const parts = [ + `纯币${formatHafCoinM(getCoinWan(item))}`, + formatInsuranceSlotText(readAssetString(item, "season_insurance")), + formatLevelShort(readAssetString(item, "stamina_level"), "体"), + formatLevelShort(readAssetString(item, "load_level"), "负"), + formatResourceShort(item, "armor6", "六甲"), + formatResourceShort(item, "helmet6", "六头"), + ...getSkinNames(item).slice(0, 4), + ].filter(Boolean); + return parts.join("/"); +} + +export function getListingSubtitle(item: Listing) { + return getValuePerYuanText(item); +} + +export function getListingChips(item: Listing): ListingDisplayChip[] { + const totalAsset = readAssetNumber(item, "total_asset_wan"); + const chips: ListingDisplayChip[] = [ + { label: "哈夫币", value: formatHafCoinM(getCoinWan(item)) }, + { label: "保险格数", value: readAssetString(item, "season_insurance") }, + { label: "体力", value: readAssetString(item, "stamina_level") }, + { label: "负重", value: readAssetString(item, "load_level") }, + { label: "段位", value: item.rank_level }, + ]; + const awmAmmo = getResourceQuantity(item, "awmAmmo"); + if (awmAmmo > 0) { + chips.push({ label: "AWM", value: `${awmAmmo}发` }); + } + if (totalAsset > 0) { + chips.push({ label: "总资产", value: formatHafCoinM(totalAsset) }); + } + const online = getOnlineTimeText(item); + if (online) { + chips.push({ label: "方便上号", value: online }); + } + return chips.filter((chip) => chip.value); +} + +export function getListingResources(item: Listing): ListingDisplayResource[] { + const resources = item.asset_summary?.resources; + if (!Array.isArray(resources)) return []; + return resources + .map((resource) => { + if (typeof resource !== "object" || resource === null) return null; + const row = resource as Record; + return { + key: typeof row.key === "string" ? row.key : "", + label: typeof row.label === "string" ? row.label : "", + price: typeof row.price === "string" ? row.price : "", + quantity: readUnknownNumber(row.quantity), + mode: typeof row.mode === "string" ? row.mode : "", + amount: + row.mode === "收费" + ? Math.round(readUnknownNumber(row.quantity) * readUnitPrice(typeof row.price === "string" ? row.price : "")) + : 0, + }; + }) + .filter((resource): resource is ListingDisplayResource => { + return Boolean(resource?.key && resource.label && resource.quantity > 0); + }); +} + +export function getResourceQuantity(item: Listing, resourceKey: string) { + return getListingResources(item).find((resource) => resource.key === resourceKey)?.quantity || 0; +} + +export function hasGiftResources(item: Listing) { + return getListingResources(item).some((resource) => resource.mode === "赠送"); +} + +export function hasAcceleratedSaleRatio(item: Listing) { + if (item.is_accelerated_sale) return true; + + const priceBreakdown = item.asset_summary?.price_breakdown; + if (typeof priceBreakdown !== "object" || priceBreakdown === null) return false; + + const row = priceBreakdown as Record; + const referenceRatio = readUnknownNumber(row.seller_reference_ratio); + const sellerRatio = readUnknownNumber(row.seller_ratio); + const acceleratedRatio = readUnknownNumber(row.accelerated_sale_ratio); + + if (referenceRatio <= 0) return false; + return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio; +} + +export function getSkinGroup(item: Listing, groupKey: string) { + const skinGroups = item.asset_summary?.skin_groups; + if ( + typeof skinGroups !== "object" || + skinGroups === null || + !Array.isArray((skinGroups as Record)[groupKey]) + ) { + return []; + } + return ((skinGroups as Record)[groupKey] as unknown[]).filter( + (skin): skin is string => typeof skin === "string" + ); +} + +export function getSkinNames(item: Listing) { + const skinGroups = item.asset_summary?.skin_groups; + if (typeof skinGroups !== "object" || skinGroups === null) return []; + return Object.values(skinGroups as Record) + .flatMap((group) => (Array.isArray(group) ? group : [])) + .filter((skin): skin is string => typeof skin === "string"); +} + +export function assetRegions(item: Listing) { + const regions = item.asset_summary?.common_regions; + return Array.isArray(regions) + ? regions.filter((region): region is string => typeof region === "string") + : []; +} + +export function getOnlineTimeText(item: Listing) { + const onlineTime = item.asset_summary?.online_time; + if (typeof onlineTime !== "object" || onlineTime === null) return ""; + const start = (onlineTime as Record).start; + const end = (onlineTime as Record).end; + if (typeof start !== "string" || typeof end !== "string" || !start || !end) return ""; + return `${start.replace(":00", "")}-${end.replace(":00", "")}点`; +} + +export function getDailyLoss(item: Listing) { + const dailyLossM = getDailyLossM(item); + return dailyLossM > 0 ? `${formatCompactNumber(dailyLossM)}M` : ""; +} + +export function getDailyLossM(item: Listing) { + const configuredLoss = readAssetNumber(item, "daily_loss_m"); + if (configuredLoss > 0) return configuredLoss; + + const coinWan = getCoinWan(item); + if (coinWan >= 30000) return 30; + if (coinWan >= 10000) return 20; + return 10; +} + +export function getEstimatedRentalDays(item: Listing) { + const coinM = getCoinM(item); + const dailyLossM = getDailyLossM(item); + if (coinM <= 0 || dailyLossM <= 0) return 0; + return coinM / dailyLossM; +} + +export function formatEstimatedRentalDuration(item: Listing) { + const days = getEstimatedRentalDays(item); + if (days <= 0) return "--"; + return `${Math.max(1, Math.round(days))}天`; +} + +export function readAssetString(item: Listing, key: string) { + const value = item.asset_summary?.[key]; + return typeof value === "string" ? value : ""; +} + +export function readAssetNumber(item: Listing, key: string) { + return readUnknownNumber(item.asset_summary?.[key]); +} + +function readUnknownNumber(value: unknown) { + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function readPriceBreakdownNumber(item: Listing, key: string) { + const priceBreakdown = item.asset_summary?.price_breakdown; + if (typeof priceBreakdown !== "object" || priceBreakdown === null) return 0; + return readUnknownNumber((priceBreakdown as Record)[key]); +} + +function readUnitPrice(priceText: string) { + const normalized = priceText.replace(/,/g, ",").trim(); + const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/); + if (fractionMatch) { + const amount = Number(fractionMatch[1]); + const count = Number(fractionMatch[2]); + return count > 0 ? amount / count : 0; + } + const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/); + return singleMatch ? Number(singleMatch[1]) : 0; +} + +function formatInsuranceSlotText(value: string) { + const parts = value.split("*").map((item) => Number(item)); + const rows = parts[0] || 0; + const cols = parts[1] || 0; + if (!Number.isFinite(rows) || !Number.isFinite(cols) || rows <= 0 || cols <= 0) { + return value; + } + return `${rows * cols}格`; +} + +function formatLevelShort(value: string, suffix: string) { + const level = value.match(/\d+/)?.[0]; + return level ? `${level}${suffix}` : value; +} + +function formatResourceShort(item: Listing, key: string, label: string) { + const quantity = getResourceQuantity(item, key); + return quantity > 0 ? `${quantity}${label}` : ""; +} + +function roundMoney(value: number) { + return Math.round(value * 100) / 100; +} + +function formatRatioNumber(value: number) { + const rounded = roundMoney(value); + return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2); +} + +function formatCompactNumber(value: number) { + const rounded = Math.round(value * 10) / 10; + return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1); +} diff --git a/frontend/src/shared/utils/pricing.ts b/frontend/src/shared/utils/pricing.ts new file mode 100644 index 0000000..847963e --- /dev/null +++ b/frontend/src/shared/utils/pricing.ts @@ -0,0 +1,288 @@ +import type { + ChargeMode, + ListingPublishOptions, + PublishDepositRecommendConfig, + PublishOptionGroup, + PublishQuantityItem, + PublishRatioConfig, + PublishSalePriceConfig, +} from '@/api/listingOptions' +import type { DepositBreakdownItem, PublishForm, PublishPlatformPricing } from '@/types/publish' + +export const dailyLossOptions = [10, 20, 30, 40, 50] +export const commonOnlineTimes = ['00:00', '08:00', '10:00', '12:00', '14:00', '18:00', '20:00', '22:00', '23:59'] + +export function roundMoney(value: number) { + return Math.round(value) +} + +export function roundRatio(value: number) { + return Math.round(value * 10) / 10 +} + +export function formatNumber(value: number) { + return Number.isInteger(value) ? `${value}` : `${Math.round(value * 10) / 10}` +} + +export function readUnitPrice(priceText: string) { + const normalized = priceText.replace(/,/g, ',').trim() + const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/) + if (fractionMatch) { + const amount = Number(fractionMatch[1]) + const count = Number(fractionMatch[2]) + return count > 0 ? amount / count : 0 + } + const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/) + return singleMatch ? Number(singleMatch[1]) : 0 +} + +export function calculateDailyLossRatioAdjustment(dailyLossMAmount: number) { + return Math.min(Math.max(Math.floor((dailyLossMAmount - 10) / 10), 0), 4) +} + +export function isGridCardQuantityItem(item: { key: string; label: string }) { + return item.key === 'gridCard9' || item.label.includes('9格体验卡') +} + +export function isQuantityItemDisabledForInsurance(item: { key: string; label: string }, seasonInsurance: string) { + return seasonInsurance === '3*3' && isGridCardQuantityItem(item) +} + +export function calculateConsumablePrice(options: { + quantityItems: PublishQuantityItem[] + quantityValues: Record + quantityModes: Record + seasonInsurance: string +}) { + const total = options.quantityItems.reduce((sum, item) => { + const quantity = Number(options.quantityValues[item.key] || 0) + const mode = options.quantityModes[item.key] || '收费' + if (isQuantityItemDisabledForInsurance(item, options.seasonInsurance)) return sum + if (quantity <= 0 || mode !== '收费') return sum + return sum + quantity * readUnitPrice(item.price) + }, 0) + return roundMoney(total) +} + +export function calculateRecommendedDeposit(options: { + depositRecommendConfig: PublishDepositRecommendConfig + skinGroups: PublishOptionGroup[] + selectedSkins: string[] +}) { + const baseAmount = Number(options.depositRecommendConfig.base_amount || 0) + const skinAmount = options.depositRecommendConfig.skin_group_rules.reduce((sum, rule) => { + const group = options.skinGroups.find((item) => item.key === rule.group_key) + if (!group) return sum + const selectedCount = group.options.filter((skin) => options.selectedSkins.includes(skin)).length + return sum + selectedCount * Number(rule.amount_per_item || 0) + }, 0) + return roundMoney(baseAmount + skinAmount) +} + +export function buildDepositBreakdownItems(options: { + depositRecommendConfig: PublishDepositRecommendConfig + skinGroups: PublishOptionGroup[] + selectedSkins: string[] +}): DepositBreakdownItem[] { + const items: DepositBreakdownItem[] = [ + { + label: '基础押金', + amount: Number(options.depositRecommendConfig.base_amount || 0), + count: 1, + }, + ] + for (const rule of options.depositRecommendConfig.skin_group_rules) { + const group = options.skinGroups.find((item) => item.key === rule.group_key) + if (!group) continue + const count = group.options.filter((skin) => options.selectedSkins.includes(skin)).length + if (count <= 0) continue + items.push({ + label: rule.label, + amount: Number(rule.amount_per_item || 0) * count, + count, + }) + } + return items +} + +export function calculateSellerReferenceRatio(options: { + coinMAmount: number + form: PublishForm + ratioConfig: PublishRatioConfig + skinGroups: PublishOptionGroup[] + selectedSkins: string[] + levelOptions: string[] + dailyLossRatioAdjustment: number +}) { + const { coinMAmount, form, ratioConfig } = options + if (coinMAmount <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level) return 0 + const baseRatio = getInsuranceBaseRatio(ratioConfig, form.season_insurance) + if (baseRatio <= 0) return 0 + return ( + baseRatio + + calculateConfigPenalty(ratioConfig, options) + + getCoinCorrection(ratioConfig, coinMAmount) + + options.dailyLossRatioAdjustment + ) +} + +export function readFinalSaleRatio(defaultRatio: number, acceleratedSaleRatio: number | '', maxAcceleratedSaleRatio: number) { + if (defaultRatio <= 0) return 0 + if (!hasAcceleratedSaleRatioInput(acceleratedSaleRatio)) return defaultRatio + const ratio = Number(acceleratedSaleRatio) + if (!Number.isFinite(ratio) || ratio <= 0) return defaultRatio + return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio)) +} + +export function hasAcceleratedSaleRatioInput(value: number | '') { + return value !== '' && value !== null +} + +export function calculatePlatformPricing(options: { + coinMAmount: number + coinWanAmount: number + sellerRatio: number + sellerCoinBasePrice: number + sellerTotalPrice: number + consumablePrice: number + salePriceConfig: PublishSalePriceConfig +}): PublishPlatformPricing { + if (options.sellerRatio <= 0 || options.sellerCoinBasePrice <= 0) return emptyPlatformPricing() + const fixedRule = findSaleFixedMarkupRule(options.salePriceConfig, options.coinMAmount) + if (fixedRule) { + return buildPlatformPricing( + roundMoney(options.sellerCoinBasePrice + Number(fixedRule.markup_amount || 0)), + 'fixed_markup', + options, + ) + } + const ratioRule = findSaleRatioAdjustmentRule(options.salePriceConfig, options.coinMAmount) + const ratioSubtract = ratioRule ? Number(ratioRule.ratio_subtract || 0) : 0 + const buyerRatio = options.sellerRatio - ratioSubtract + if (buyerRatio > 0 && ratioRule) { + return buildPlatformPricing(roundMoney(options.coinWanAmount / buyerRatio), 'ratio_subtract', options) + } + return buildPlatformPricing(options.sellerCoinBasePrice, 'none', options) +} + +export function emptyPlatformPricing(): PublishPlatformPricing { + return { + buyerCoinBasePrice: 0, + buyerTotalPrice: 0, + buyerRatio: 0, + platformMarkupAmount: 0, + ruleType: 'none', + } +} + +function buildPlatformPricing( + buyerCoinBasePrice: number, + ruleType: string, + options: { + coinWanAmount: number + sellerTotalPrice: number + consumablePrice: number + }, +): PublishPlatformPricing { + const buyerTotalPrice = roundMoney(buyerCoinBasePrice + options.consumablePrice) + return { + buyerCoinBasePrice, + buyerTotalPrice, + buyerRatio: calculateEffectiveRatio(options.coinWanAmount, buyerCoinBasePrice), + platformMarkupAmount: roundMoney(buyerTotalPrice - options.sellerTotalPrice), + ruleType, + } +} + +function findSaleFixedMarkupRule(config: PublishSalePriceConfig, coinMAmount: number) { + return [...config.fixed_markup_rules] + .sort((a, b) => a.min_m - b.min_m) + .find((item, index, rules) => isCoinInSaleRange(item, index, rules, coinMAmount, { includeLastMax: true })) +} + +function findSaleRatioAdjustmentRule(config: PublishSalePriceConfig, coinMAmount: number) { + return [...config.ratio_adjustment_rules] + .sort((a, b) => a.min_m - b.min_m) + .find((item, index, rules) => isCoinInSaleRange(item, index, rules, coinMAmount, { excludeFirstMin: true })) +} + +function isCoinInSaleRange( + item: { min_m: number; max_m: number }, + index: number, + rules: Array<{ min_m: number; max_m: number }>, + coinMAmount: number, + options: { includeLastMax?: boolean; excludeFirstMin?: boolean } = {}, +) { + const maxM = Number(item.max_m || 0) + const minM = Number(item.min_m || 0) + const minMatched = options.excludeFirstMin && index === 0 ? coinMAmount > minM : coinMAmount >= minM + const isLastRule = index === rules.length - 1 + const maxMatched = maxM <= 0 || coinMAmount < maxM || (options.includeLastMax && isLastRule && coinMAmount <= maxM) + return minMatched && maxMatched +} + +function calculateEffectiveRatio(coinWanAmount: number, price: number) { + if (price <= 0) return 0 + return roundRatio(coinWanAmount / price) +} + +function getInsuranceBaseRatio(config: Pick, insurance: string) { + return config.insurance_base_ratios.find((item) => item.insurance === insurance)?.ratio || 0 +} + +function calculateConfigPenalty( + config: Pick, + options: { + form: PublishForm + skinGroups: PublishOptionGroup[] + selectedSkins: string[] + levelOptions: string[] + }, +) { + return config.config_items.reduce((sum, item) => { + return isRatioConfigItemMatched(item, options) ? sum : sum + Number(item.missing_penalty || 0) + }, 0) +} + +function isRatioConfigItemMatched( + item: { kind: string; group_key?: string }, + options: { + form: PublishForm + skinGroups: PublishOptionGroup[] + selectedSkins: string[] + levelOptions: string[] + }, +) { + if (item.kind === 'skin_group') return hasSelectedSkinGroup(item.group_key || '', options) + if (item.kind === 'max_stamina') return isMaxLevel(options.form.stamina_level, options.levelOptions) + if (item.kind === 'max_load') return isMaxLevel(options.form.load_level, options.levelOptions) + return false +} + +function hasSelectedSkinGroup( + groupKey: string, + options: { + skinGroups: PublishOptionGroup[] + selectedSkins: string[] + }, +) { + const group = options.skinGroups.find((item) => item.key === groupKey) + if (!group) return false + return group.options.some((skin) => options.selectedSkins.includes(skin)) +} + +function isMaxLevel(value: string, levelOptions: string[]) { + const currentLevel = readLevelNumber(value) + const maxLevel = Math.max(...levelOptions.map(readLevelNumber).filter(Boolean)) + if (currentLevel > 0 && maxLevel > 0) return currentLevel >= maxLevel + return value === levelOptions[levelOptions.length - 1] +} + +function readLevelNumber(value: string) { + const match = value.match(/\d+/) + return match ? Number(match[0]) : 0 +} + +function getCoinCorrection(config: Pick, coinM: number) { + return [...config.coin_corrections].sort((a, b) => b.threshold_m - a.threshold_m).find((item) => coinM > item.threshold_m)?.correction || 0 +} diff --git a/frontend/src/shared/utils/statusLabels.ts b/frontend/src/shared/utils/statusLabels.ts new file mode 100644 index 0000000..adfa8d9 --- /dev/null +++ b/frontend/src/shared/utils/statusLabels.ts @@ -0,0 +1,175 @@ +import type { + BalanceType, + DisputeStatus, + HandoffStatus, + LedgerDirection, + ListingReviewStatus, + ListingStatus, + OrderStatus, + RealnameStatusValue, + RiskStatus, + SettlementStatus, + UserStatus, + WalletStatus, +} from '@/types/status' + +const listingStatusMap: Record = { + draft: '草稿', + published: '已上架', + rented: '租用中', + offline: '已下架', + abnormal: '异常', +} + +const listingReviewStatusMap: Record = { + none: '未提交', + pending: '待审核', + approved: '已通过', + rejected: '已拒绝', +} + +const orderStatusMap: Record = { + pending_confirm: '待确认', + pending_payment: '待支付', + pending_handoff: '待交接', + renting: '使用中', + overdue: '已逾期', + pending_return_confirm: '待结账确认', + pending_checkout_confirm: '待号主确认结账', + pending_checkout_accept: '待租客确认修正', + checkout_disputing: '结账争议中', + completed: '已完成', + cancelled: '已取消', + closed: '已关闭', + disputing: '申诉中', + abnormal: '异常', +} + +const handoffStatusMap: Record = { + pending_owner: '待号主交接', + pending_renter_confirm: '待租客确认', + received: '已确认收号', + pending_owner_return_confirm: '待号主确认结账', + pending_owner_checkout: '待号主确认结账', + pending_renter_checkout: '待租客确认修正', + checkout_disputed: '结账争议中', + returned: '已归还', + cancelled: '已取消', + owner_timeout: '号主交接超时', + renter_confirm_timeout: '租客确认超时', + return_overdue: '归还逾期', + owner_return_confirm_timeout: '号主确认结账超时', + owner_checkout_confirm_timeout: '号主确认结账超时', + admin_closed: '客服关闭', + admin_abnormal: '客服标记异常', + arbitrated: '已仲裁', +} + +const settlementStatusMap: Record = { + unsettled: '未结算', + pending: '待结算', + frozen: '冻结中', + settled: '已结算', + refunded: '已退款', + cancelled: '已取消', + closed: '已关闭', + disputed: '争议中', + arbitrated: '已仲裁', +} + +const realnameStatusMap: Partial> = { + unverified: '未认证', + pending: '认证中', + verified: '已认证', + rejected: '认证失败', +} + +const userStatusMap: Record = { + active: '正常', + frozen: '已冻结', + disabled: '已禁用', +} + +const riskStatusMap: Record = { + normal: '正常', + watch: '观察', + restricted: '受限', + blocked: '已拦截', +} + +const disputeStatusMap: Record = { + open: '待处理', + processing: '处理中', + resolved: '已处理', + closed: '已关闭', +} + +const walletStatusMap: Record = { + active: '正常', + frozen: '已冻结', + disabled: '已禁用', +} + +const ledgerDirectionMap: Record = { + in: '收入', + out: '支出', + freeze: '冻结', + unfreeze: '解冻', +} + +const balanceTypeMap: Record = { + available: '可用余额', + frozen: '冻结余额', +} + +function readLabel(map: Record, value: string) { + return map[value] || value || '-' +} + +export function listingStatusLabel(status: string) { + return readLabel(listingStatusMap, status) +} + +export function listingReviewStatusLabel(status: string) { + return readLabel(listingReviewStatusMap, status) +} + +export function orderStatusLabel(status: string) { + return readLabel(orderStatusMap, status) +} + +export function handoffStatusLabel(status: string) { + return readLabel(handoffStatusMap, status) +} + +export function settlementStatusLabel(status: string) { + return readLabel(settlementStatusMap, status) +} + +export function realnameStatusLabel(status: string) { + return readLabel(realnameStatusMap, status) +} + +export function userStatusLabel(status: string) { + return readLabel(userStatusMap, status) +} + +export function riskStatusLabel(status: string) { + return readLabel(riskStatusMap, status) +} + +export function disputeStatusLabel(status: string) { + return readLabel(disputeStatusMap, status) +} + +export function walletStatusLabel(status: string) { + return readLabel(walletStatusMap, status) +} + +export function ledgerDirectionLabel(direction: string) { + return readLabel(ledgerDirectionMap, direction) +} + +export function balanceTypeLabel(type: string) { + return readLabel(balanceTypeMap, type) +} diff --git a/frontend/src/shared/utils/systemConfigOptions.ts b/frontend/src/shared/utils/systemConfigOptions.ts new file mode 100644 index 0000000..dad46fb --- /dev/null +++ b/frontend/src/shared/utils/systemConfigOptions.ts @@ -0,0 +1,65 @@ +export interface SystemConfigOption { + label: string + value: string +} + +const shortTimeoutOptions: SystemConfigOption[] = [ + { label: '不限时', value: '0' }, + { label: '5 分钟', value: '5' }, + { label: '10 分钟', value: '10' }, + { label: '15 分钟', value: '15' }, + { label: '30 分钟', value: '30' }, + { label: '45 分钟', value: '45' }, + { label: '60 分钟', value: '60' }, + { label: '90 分钟', value: '90' }, + { label: '120 分钟', value: '120' }, +] + +const longTimeoutOptions: SystemConfigOption[] = [ + { label: '不限时', value: '0' }, + { label: '30 分钟', value: '30' }, + { label: '60 分钟', value: '60' }, + { label: '120 分钟', value: '120' }, + { label: '180 分钟', value: '180' }, + { label: '240 分钟', value: '240' }, + { label: '360 分钟', value: '360' }, + { label: '12 小时', value: '720' }, + { label: '24 小时', value: '1440' }, + { label: '48 小时', value: '2880' }, +] + +const booleanOptions: SystemConfigOption[] = [ + { label: '开启', value: 'true' }, + { label: '关闭', value: 'false' }, +] + +export const systemConfigSelectOptions: Record = { + 'handoff.owner_submit_timeout_minutes': shortTimeoutOptions, + 'handoff.renter_confirm_timeout_minutes': shortTimeoutOptions, + 'handoff.owner_return_confirm_timeout_minutes': longTimeoutOptions, + 'order.pending_payment_timeout_minutes': shortTimeoutOptions, + 'order.return_overdue_grace_minutes': [ + { label: '无宽限', value: '0' }, + { label: '5 分钟', value: '5' }, + { label: '10 分钟', value: '10' }, + { label: '15 分钟', value: '15' }, + { label: '30 分钟', value: '30' }, + { label: '60 分钟', value: '60' }, + { label: '120 分钟', value: '120' }, + ], + 'listing.review_required': booleanOptions, + 'chat.default_support_admin_id': [ + { label: '管理员 ID 1', value: '1' }, + { label: '管理员 ID 2', value: '2' }, + { label: '管理员 ID 3', value: '3' }, + ], +} + +export function getSystemConfigSelectOptions(key: string) { + return systemConfigSelectOptions[key] || null +} + +export function formatSystemConfigSelectValue(key: string, value: string) { + const option = getSystemConfigSelectOptions(key)?.find((item) => item.value === value) + return option?.label || null +} diff --git a/frontend/src/shared/utils/time.ts b/frontend/src/shared/utils/time.ts new file mode 100644 index 0000000..cf1b804 --- /dev/null +++ b/frontend/src/shared/utils/time.ts @@ -0,0 +1,23 @@ +type DateInput = string | number | Date | null | undefined + +export function formatDateTime(value: DateInput, fallback = '-') { + if (!value) return fallback + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return typeof value === 'string' && value.trim() ? value.replace('T', ' ') : fallback + } + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + ].join('-') + ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +} + +export function formatDateMinute(value: DateInput, fallback = '-') { + const formatted = formatDateTime(value, fallback) + return formatted === fallback ? fallback : formatted.slice(0, 16) +} + +function pad(value: number) { + return String(value).padStart(2, '0') +}