From 41f7059585f6b1842a8e36ffef7ffe08efe3b7a6 Mon Sep 17 00:00:00 2001 From: erio Date: Fri, 13 Mar 2026 22:03:06 +0800 Subject: [PATCH] =?UTF-8?q?revert:=20=E5=8E=BB=E6=8E=89=E6=80=BB=E8=A7=88?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=8C/admin=20=E7=9B=B4=E6=8E=A5=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=AE=A2=E5=8D=95=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/plan.md | 410 +++++++++++++++++++++++++++ src/app/admin/channels/page.tsx | 2 +- src/app/admin/dashboard/page.tsx | 2 +- src/app/admin/layout.tsx | 3 +- src/app/admin/orders/page.tsx | 349 ----------------------- src/app/admin/page.tsx | 378 +++++++++++++++++------- src/app/admin/subscriptions/page.tsx | 2 +- 7 files changed, 696 insertions(+), 450 deletions(-) create mode 100644 .claude/plan.md delete mode 100644 src/app/admin/orders/page.tsx diff --git a/.claude/plan.md b/.claude/plan.md new file mode 100644 index 0000000..e269813 --- /dev/null +++ b/.claude/plan.md @@ -0,0 +1,410 @@ +# Sub2ApiPay 改造方案 + +## 一、概述 + +基于 Pincc 参考界面,改造 Sub2ApiPay 项目,新增: +- **用户页面**:双 Tab(按量付费 / 包月套餐),渠道卡片展示,充值弹窗,订阅购买流程 +- **管理员界面**:渠道管理、订阅套餐管理、系统配置 +- **数据库存储配置**:支付渠道等配置从环境变量迁移至数据库,支持运行时修改 + +--- + +## 二、数据库 Schema 变更 + +### 2.1 新增模型 + +```prisma +// 渠道展示配置(管理员配置,对应 Sub2API 的 group) +model Channel { + id String @id @default(cuid()) + groupId Int @unique @map("group_id") // Sub2API group ID + name String // 显示名称 + platform String @default("claude") // 分类: claude/openai/gemini/codex + rateMultiplier Decimal @db.Decimal(10, 4) @map("rate_multiplier") // 倍率 + description String? @db.Text // 描述 + models String? @db.Text // JSON数组: 支持的模型列表 + features String? @db.Text // JSON数组: 功能特性列表 + sortOrder Int @default(0) @map("sort_order") // 排序 + enabled Boolean @default(true) // 是否启用 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([sortOrder]) + @@map("channels") +} + +// 订阅套餐配置(管理员配置价格后才可售卖) +model SubscriptionPlan { + id String @id @default(cuid()) + groupId Int @unique @map("group_id") // Sub2API group ID + name String // 套餐名称 + description String? @db.Text // 描述 + price Decimal @db.Decimal(10, 2) // CNY 价格 + originalPrice Decimal? @db.Decimal(10, 2) @map("original_price") // 原价(划线价) + validityDays Int @default(30) @map("validity_days") // 有效期天数 + features String? @db.Text // JSON数组: 特性描述 + forSale Boolean @default(false) @map("for_sale") // 是否启用售卖 + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + orders Order[] + + @@index([forSale, sortOrder]) + @@map("subscription_plans") +} + +// 系统配置(键值对,支持运行时修改) +model SystemConfig { + key String @id + value String @db.Text + group String @default("general") // general / payment / limits / display + label String? // 配置项显示名称 + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([group]) + @@map("system_configs") +} +``` + +### 2.2 扩展 Order 模型 + +在现有 Order 模型上新增字段,复用整套支付流程: + +```prisma +model Order { + // ... 现有字段不变 ... + + // 新增:订单类型 + orderType String @default("balance") @map("order_type") // "balance" | "subscription" + + // 新增:订阅相关(orderType="subscription" 时有值) + planId String? @map("plan_id") + plan SubscriptionPlan? @relation(fields: [planId], references: [id]) + subscriptionGroupId Int? @map("subscription_group_id") // Sub2API group ID + subscriptionDays Int? @map("subscription_days") // 购买时的有效天数 + + // 新增索引 + @@index([orderType]) +} +``` + +**设计理由**:订阅订单和余额充值订单共享同一套支付流程(创建→支付→回调→履约),仅在最终「履约」步骤不同: +- `balance`:调用 `createAndRedeem()` 充值余额 +- `subscription`:调用 Sub2API `POST /admin/subscriptions/assign` 分配订阅 + +--- + +## 三、Sub2API Client 扩展 + +在 `src/lib/sub2api/client.ts` 新增方法: + +```typescript +// 获取所有分组(管理员) +async function getAllGroups(): Promise +// GET /api/v1/admin/groups/all + +// 获取单个分组 +async function getGroup(groupId: number): Promise +// GET /api/v1/admin/groups/:id + +// 分配订阅(支付成功后调用) +async function assignSubscription(userId: number, groupId: number, validityDays: number, notes?: string): Promise +// POST /api/v1/admin/subscriptions/assign + +// 获取用户的订阅列表 +async function getUserSubscriptions(userId: number): Promise +// GET /api/v1/admin/users/:id/subscriptions + +// 延长订阅(续费) +async function extendSubscription(subscriptionId: number, days: number): Promise +// POST /api/v1/admin/subscriptions/:id/extend +``` + +类型定义: +```typescript +interface Sub2ApiGroup { + id: number + name: string + description: string + platform: string + status: string + rate_multiplier: number + subscription_type: string + daily_limit_usd: number | null + weekly_limit_usd: number | null + monthly_limit_usd: number | null + default_validity_days: number + sort_order: number +} + +interface Sub2ApiSubscription { + id: number + user_id: number + group_id: number + starts_at: string + expires_at: string + status: string + daily_usage_usd: number + weekly_usage_usd: number + monthly_usage_usd: number + notes: string | null +} +``` + +--- + +## 四、API 路由新增 + +### 4.1 用户 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/channels` | 获取已启用的渠道列表(用户token验证 + 校验Sub2API分组是否存在) | +| GET | `/api/subscription-plans` | 获取可售卖的订阅套餐列表(同上校验) | +| POST | `/api/orders` | **扩展**:支持 `order_type: "subscription"` + `plan_id` | +| GET | `/api/subscriptions/my` | 获取当前用户的活跃订阅列表 | + +### 4.2 管理员 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/admin/channels` | 渠道列表(含Sub2API分组同步状态) | +| POST | `/api/admin/channels` | 创建/更新渠道 | +| PUT | `/api/admin/channels/[id]` | 更新渠道 | +| DELETE | `/api/admin/channels/[id]` | 删除渠道 | +| GET | `/api/admin/sub2api/groups` | 从Sub2API拉取所有分组(供选择) | +| GET | `/api/admin/subscription-plans` | 订阅套餐列表 | +| POST | `/api/admin/subscription-plans` | 创建套餐 | +| PUT | `/api/admin/subscription-plans/[id]` | 更新套餐 | +| DELETE | `/api/admin/subscription-plans/[id]` | 删除套餐 | +| GET | `/api/admin/subscriptions` | 所有用户的订阅列表 | +| GET | `/api/admin/config` | 获取系统配置 | +| PUT | `/api/admin/config` | 批量更新系统配置 | + +--- + +## 五、订单服务改造 + +### 5.1 订单创建(扩展 `createOrder`) + +```typescript +interface CreateOrderInput { + // 现有字段... + orderType?: 'balance' | 'subscription' // 新增 + planId?: string // 新增(订阅时必填) +} +``` + +订阅订单创建时的校验逻辑: +1. 验证 `planId` 对应的 SubscriptionPlan 存在且 `forSale=true` +2. 调用 Sub2API 验证 `groupId` 对应的分组仍然存在且 status=active +3. 金额使用 plan.price(不允许用户自定义) +4. 其余流程(支付方式选择、限额检查等)与余额订单一致 + +### 5.2 订单履约(修改 `executeRecharge` → `executeFulfillment`) + +``` +if (order.orderType === 'subscription') { + // 1. 再次验证 Sub2API 分组存在 + const group = await getGroup(order.subscriptionGroupId) + if (!group || group.status !== 'active') { + // 标记 FAILED,reason = "订阅分组已不存在" + // 前端展示常驻错误提示 + return + } + // 2. 调用 Sub2API 分配订阅 + await assignSubscription(order.userId, order.subscriptionGroupId, order.subscriptionDays) + // 3. 标记 COMPLETED +} else { + // 原有余额充值逻辑不变 + await createAndRedeem(...) +} +``` + +### 5.3 订阅退款 + +订阅订单的退款需要额外步骤:撤销 Sub2API 中的订阅(`DELETE /admin/subscriptions/:id`)。 +如果撤销失败,标记为 REFUND_FAILED 并记录审计日志,需人工介入。 + +--- + +## 六、用户页面改造 + +### 6.1 页面结构(参考 Pincc 的 top-up-main.png) + +``` +/pay 页面 +├── 顶部标题区:"选择适合你的 订阅套餐" +├── 双 Tab 切换:[ 按量付费 | 包月套餐 ] +│ +├── Tab 1: 按量付费 +│ ├── Banner: 按量付费模式说明(倍率换算、余额通用等) +│ ├── 渠道卡片网格(3列,从 /api/channels 获取) +│ │ └── 每张卡片:平台标签 + 名称 + 倍率 + 余额换算 + 描述 + 模型标签 + 功能标签 + "立即充值" 按钮 +│ └── 点击"立即充值" → 弹出充值金额选择弹窗(参考 top-up.png) +│ └── 金额网格(管理员可配置档位)→ "确认充值" → 支付方式选择 → 支付流程 +│ +├── Tab 2: 包月套餐 +│ ├── 订阅套餐卡片(从 /api/subscription-plans 获取) +│ │ └── 每张卡片:套餐名 + 价格/月 + 划线原价 + 限额特性列表 + "立即开通" 按钮 +│ └── 点击"立即开通" → 确认订单页(参考 subscribe.png) +│ └── 套餐详情 + 价格 + 选择支付方式 + "立即购买" +│ (注:我们的用户已通过 token 认证,不需要 Pincc 的邮箱/密码输入框) +│ +├── 用户已有订阅展示区 +│ └── 活跃订阅列表 + 到期提醒 + "续费" 按钮 +│ +└── 底部:购买流程说明 + 温馨提示 +``` + +**条件逻辑**: +- 如果管理员 **没有配置渠道**(Channel 表为空)→ 直接显示现有的充值界面(PaymentForm),不显示卡片 +- 如果管理员 **配置了渠道** → 显示渠道卡片网格,点击"立即充值"弹出金额选择弹窗 +- 如果管理员 **没有配置订阅套餐**(SubscriptionPlan 无 forSale=true)→ 隐藏"包月套餐" Tab + +### 6.2 新增组件 + +| 组件 | 说明 | +|------|------| +| `ChannelCard.tsx` | 渠道卡片(平台标签、倍率、模型标签等) | +| `ChannelGrid.tsx` | 渠道卡片网格容器 | +| `TopUpModal.tsx` | 充值金额选择弹窗 | +| `SubscriptionPlanCard.tsx` | 订阅套餐卡片 | +| `SubscriptionConfirm.tsx` | 订阅确认订单页 | +| `UserSubscriptions.tsx` | 用户已有订阅展示 | +| `MainTabs.tsx` | 按量付费/包月套餐 Tab 切换 | +| `PurchaseFlow.tsx` | 购买流程说明(4步骤图标) | + +### 6.3 异常处理 + +- 支付成功但订阅分组不存在:前端显示**常驻红色告警框**,包含: + - 错误说明:"您已成功支付,但订阅分组已下架,无法自动开通" + - 订单信息(订单号、金额、支付时间) + - 引导:"请联系客服处理,提供订单号 xxx" + +--- + +## 七、管理员页面新增 + +### 7.1 页面路由 + +| 路由 | 说明 | +|------|------| +| `/admin/channels` | 渠道管理(列表 + 编辑弹窗,参考 channel-conf.png) | +| `/admin/subscriptions` | 订阅套餐管理 + 已有订阅列表 | +| `/admin/settings` | 系统配置(支付渠道配置、业务参数等) | + +### 7.2 渠道管理页(/admin/channels) + +- 顶部操作栏:[从 Sub2API 同步分组] [新建渠道] +- 渠道列表表格:名称 | 分类 | 倍率 | Sub2API状态 | 排序 | 启用 | 操作 +- 编辑弹窗(参考 channel-conf.png): + - 渠道名称、分类(下拉)、倍率、描述 + - 支持模型(textarea,每行一个) + - 功能特性(textarea,每行一个) + - 排序、启用开关 +- "从 Sub2API 同步":拉取所有分组 → 显示差异 → 可选批量导入 + +### 7.3 订阅套餐管理页(/admin/subscriptions) + +两个区域: +1. **套餐配置**: + - 列表:套餐名 | 关联分组 | 价格 | 有效天数 | 启用售卖 | Sub2API状态 | 操作 + - 新建/编辑表单:选择 Sub2API 分组 → 配置名称、价格、原价、有效天数、特性描述、启用售卖 + +2. **已有订阅**: + - 从 Sub2API 查询所有订阅记录 + - 表格:用户 | 分组 | 开始时间 | 到期时间 | 状态 | 用量 + +### 7.4 系统配置页(/admin/settings) + +分组展示: +- **支付渠道配置**:PAYMENT_PROVIDERS、各支付商的 Key/密钥等(敏感字段脱敏显示) +- **业务参数**:ORDER_TIMEOUT_MINUTES、MIN/MAX_RECHARGE_AMOUNT、MAX_DAILY_RECHARGE_AMOUNT 等 +- **充值档位配置**:自定义充值金额选项(如 50/100/500/1000) +- **显示配置**:PAY_HELP_IMAGE_URL、PAY_HELP_TEXT、PAYMENT_SUBLABEL_* 等 +- **前端定制**:站点名称、联系客服信息等 + +配置优先级:**数据库配置 > 环境变量**(环境变量作为默认值/回退值) + +--- + +## 八、配置系统改造 + +### 8.1 `getConfig()` 函数改造 + +```typescript +// 新的配置读取优先级: +// 1. 数据库 SystemConfig 表(运行时可修改) +// 2. 环境变量(作为回退/初始值) + +async function getConfig(key: string): Promise { + const dbConfig = await prisma.systemConfig.findUnique({ where: { key } }) + if (dbConfig) return dbConfig.value + return process.env[key] +} + +// 批量获取(带缓存,避免频繁查DB) +async function getConfigs(keys: string[]): Promise> { ... } +``` + +### 8.2 缓存策略 + +- 使用内存缓存(Map + TTL 30秒),避免每次请求都查数据库 +- 管理员更新配置时清除缓存 +- 支付商密钥等敏感配置仍可通过环境变量传入(数据库中存储 `__FROM_ENV__` 标记表示使用环境变量值) + +--- + +## 九、管理员入口 + +管理员通过以下方式进入: +1. Sub2API 管理面板中跳转(携带 admin token) +2. 直接访问 `/admin?token=xxx`(现有机制) + +管理员页面新增导航侧边栏: +- 订单管理(现有) +- 数据概览(现有) +- **渠道管理**(新增) +- **订阅管理**(新增) +- **系统配置**(新增) + +--- + +## 十、实施顺序 + +### Phase 1:数据库 & 基础设施(预估 2-3 步) +1. Prisma schema 变更 + migration +2. SystemConfig 服务层(CRUD + 缓存) +3. Sub2API client 扩展(分组/订阅 API) + +### Phase 2:管理员 API & 页面(预估 4-5 步) +4. 渠道管理 API + 页面 +5. 订阅套餐管理 API + 页面 +6. 系统配置 API + 页面 +7. 管理员导航侧边栏 + +### Phase 3:订单服务改造(预估 2 步) +8. Order 模型扩展 + 订阅订单创建逻辑 +9. 订阅履约逻辑(executeSubscriptionFulfillment) + +### Phase 4:用户页面改造(预估 3-4 步) +10. 用户 API(channels、subscription-plans、subscriptions/my) +11. 按量付费 Tab(ChannelGrid + TopUpModal) +12. 包月套餐 Tab(SubscriptionPlanCard + SubscriptionConfirm) +13. 用户订阅展示 + 续费 + 异常处理 + +### Phase 5:配置迁移 & 收尾(预估 1-2 步) +14. getEnv() 改造(数据库优先 + 环境变量回退) +15. 测试 + 端到端验证 + +--- + +## 十一、安全考虑 + +1. **订阅分组校验**:每次展示和下单都实时校验 Sub2API 分组是否存在且 active +2. **价格篡改防护**:订阅订单金额从服务端 SubscriptionPlan.price 读取,不信任客户端传值 +3. **支付后分组消失**:订单标记 FAILED + 常驻错误提示 + 审计日志,不自动退款(需人工确认) +4. **敏感配置**:支付密钥在 API 响应中脱敏,前端仅展示 `****...最后4位` +5. **幂等性**:订阅分配使用 `orderId` 作为幂等 key,防止重复分配 diff --git a/src/app/admin/channels/page.tsx b/src/app/admin/channels/page.tsx index 6d2df3e..d511cf1 100644 --- a/src/app/admin/channels/page.tsx +++ b/src/app/admin/channels/page.tsx @@ -502,7 +502,7 @@ function ChannelsContent() { locale={locale} actions={ <> - + {t.orders} ))} - + {text.orders} - - } - > - {error && ( -
- {error} - -
- )} - - {/* Filters */} -
- {statuses.map((s) => ( - - ))} -
- - {/* Table */} -
- {loading ? ( -
{text.loading}
- ) : ( - - )} -
- - setPage(p)} - onPageSizeChange={(s) => { - setPageSize(s); - setPage(1); - }} - locale={locale} - isDark={isDark} - /> - - {/* Order Detail */} - {detailOrder && ( - setDetailOrder(null)} dark={isDark} locale={locale} /> - )} - - ); -} - -function AdminPageFallback() { - const searchParams = useSearchParams(); - const locale = resolveLocale(searchParams.get('lang')); - - return ( -
-
{locale === 'en' ? 'Loading...' : '加载中...'}
-
- ); -} - -export default function AdminPage() { - return ( - }> - - - ); -} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1766b31..671aea4 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,54 +1,49 @@ 'use client'; import { useSearchParams } from 'next/navigation'; -import { Suspense } from 'react'; +import { useState, useEffect, useCallback, Suspense } from 'react'; +import OrderTable from '@/components/admin/OrderTable'; +import OrderDetail from '@/components/admin/OrderDetail'; +import PaginationBar from '@/components/PaginationBar'; import PayPageLayout from '@/components/PayPageLayout'; -import { resolveLocale } from '@/lib/locale'; +import { resolveLocale, type Locale } from '@/lib/locale'; -const MODULES = [ - { - path: '/admin/orders', - label: { zh: '订单管理', en: 'Order Management' }, - desc: { zh: '查看和管理所有充值订单', en: 'View and manage all recharge orders' }, - icon: ( - - - - ), - }, - { - path: '/admin/dashboard', - label: { zh: '数据概览', en: 'Dashboard' }, - desc: { zh: '收入统计与订单趋势', en: 'Revenue statistics and order trends' }, - icon: ( - - - - ), - }, - { - path: '/admin/channels', - label: { zh: '渠道管理', en: 'Channel Management' }, - desc: { zh: '配置 API 渠道与倍率', en: 'Configure API channels and rate multipliers' }, - icon: ( - - - - ), - }, - { - path: '/admin/subscriptions', - label: { zh: '订阅管理', en: 'Subscription Management' }, - desc: { zh: '管理订阅套餐与用户订阅', en: 'Manage subscription plans and user subscriptions' }, - icon: ( - - - - ), - }, -]; +interface AdminOrder { + id: string; + userId: number; + userName: string | null; + userEmail: string | null; + userNotes: string | null; + amount: number; + status: string; + paymentType: string; + createdAt: string; + paidAt: string | null; + completedAt: string | null; + failedReason: string | null; + expiresAt: string; + srcHost: string | null; +} -function AdminOverviewContent() { +interface AdminOrderDetail extends AdminOrder { + rechargeCode: string; + paymentTradeNo: string | null; + refundAmount: number | null; + refundReason: string | null; + refundAt: string | null; + forceRefund: boolean; + failedAt: string | null; + updatedAt: string; + clientIp: string | null; + srcHost: string | null; + srcUrl: string | null; + paymentSuccess?: boolean; + rechargeSuccess?: boolean; + rechargeStatus?: string; + auditLogs: { id: string; action: string; detail: string | null; operator: string | null; createdAt: string }[]; +} + +function AdminContent() { const searchParams = useSearchParams(); const token = searchParams.get('token'); const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; @@ -62,16 +57,106 @@ function AdminOverviewContent() { ? { missingToken: 'Missing admin token', missingTokenHint: 'Please access the admin page from the Sub2API platform.', - title: 'Admin Panel', - subtitle: 'Manage orders, analytics, channels and subscriptions', + invalidToken: 'Invalid admin token', + requestFailed: 'Request failed', + loadOrdersFailed: 'Failed to load orders', + retryConfirm: 'Retry recharge for this order?', + retryFailed: 'Retry failed', + retryRequestFailed: 'Retry request failed', + cancelConfirm: 'Cancel this order?', + cancelFailed: 'Cancel failed', + cancelRequestFailed: 'Cancel request failed', + loadDetailFailed: 'Failed to load order details', + title: 'Order Management', + subtitle: 'View and manage all recharge orders', + dashboard: 'Dashboard', + refresh: 'Refresh', + loading: 'Loading...', + statuses: { + '': 'All', + PENDING: 'Pending', + PAID: 'Paid', + RECHARGING: 'Recharging', + COMPLETED: 'Completed', + EXPIRED: 'Expired', + CANCELLED: 'Cancelled', + FAILED: 'Recharge failed', + REFUNDED: 'Refunded', + }, } : { missingToken: '缺少管理员凭证', missingTokenHint: '请从 Sub2API 平台正确访问管理页面', - title: '管理后台', - subtitle: '订单、数据、渠道与订阅的统一管理入口', + invalidToken: '管理员凭证无效', + requestFailed: '请求失败', + loadOrdersFailed: '加载订单列表失败', + retryConfirm: '确认重试充值?', + retryFailed: '重试失败', + retryRequestFailed: '重试请求失败', + cancelConfirm: '确认取消该订单?', + cancelFailed: '取消失败', + cancelRequestFailed: '取消请求失败', + loadDetailFailed: '加载订单详情失败', + title: '订单管理', + subtitle: '查看和管理所有充值订单', + dashboard: '数据概览', + refresh: '刷新', + loading: '加载中...', + statuses: { + '': '全部', + PENDING: '待支付', + PAID: '已支付', + RECHARGING: '充值中', + COMPLETED: '已完成', + EXPIRED: '已超时', + CANCELLED: '已取消', + FAILED: '充值失败', + REFUNDED: '已退款', + }, }; + const [orders, setOrders] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [totalPages, setTotalPages] = useState(1); + const [statusFilter, setStatusFilter] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const [detailOrder, setDetailOrder] = useState(null); + + const fetchOrders = useCallback(async () => { + if (!token) return; + setLoading(true); + try { + const params = new URLSearchParams({ token, page: String(page), page_size: String(pageSize) }); + if (statusFilter) params.set('status', statusFilter); + + const res = await fetch(`/api/admin/orders?${params}`); + if (!res.ok) { + if (res.status === 401) { + setError(text.invalidToken); + return; + } + throw new Error(text.requestFailed); + } + + const data = await res.json(); + setOrders(data.orders); + setTotal(data.total); + setTotalPages(data.total_pages); + } catch { + setError(text.loadOrdersFailed); + } finally { + setLoading(false); + } + }, [token, page, pageSize, statusFilter]); + + useEffect(() => { + fetchOrders(); + }, [fetchOrders]); + if (!token) { return (
@@ -83,67 +168,168 @@ function AdminOverviewContent() { ); } + const handleRetry = async (orderId: string) => { + if (!confirm(text.retryConfirm)) return; + try { + const res = await fetch(`/api/admin/orders/${orderId}/retry?token=${token}`, { + method: 'POST', + }); + if (res.ok) { + fetchOrders(); + } else { + const data = await res.json(); + setError(data.error || text.retryFailed); + } + } catch { + setError(text.retryRequestFailed); + } + }; + + const handleCancel = async (orderId: string) => { + if (!confirm(text.cancelConfirm)) return; + try { + const res = await fetch(`/api/admin/orders/${orderId}/cancel?token=${token}`, { + method: 'POST', + }); + if (res.ok) { + fetchOrders(); + } else { + const data = await res.json(); + setError(data.error || text.cancelFailed); + } + } catch { + setError(text.cancelRequestFailed); + } + }; + + const handleViewDetail = async (orderId: string) => { + try { + const res = await fetch(`/api/admin/orders/${orderId}?token=${token}`); + if (res.ok) { + const data = await res.json(); + setDetailOrder(data); + } + } catch { + setError(text.loadDetailFailed); + } + }; + + const statuses = ['', 'PENDING', 'PAID', 'RECHARGING', 'COMPLETED', 'EXPIRED', 'CANCELLED', 'FAILED', 'REFUNDED']; + const statusLabels: Record = text.statuses; + const navParams = new URLSearchParams(); if (token) navParams.set('token', token); if (locale === 'en') navParams.set('lang', 'en'); if (isDark) navParams.set('theme', 'dark'); if (isEmbedded) navParams.set('ui_mode', 'embedded'); + const btnBase = [ + 'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors', + isDark + ? 'border-slate-600 text-slate-200 hover:bg-slate-800' + : 'border-slate-300 text-slate-700 hover:bg-slate-100', + ].join(' '); + return ( - -
- {MODULES.map((mod) => ( - + + {text.dashboard} + + + + } + > + {error && ( +
+ {error} + +
+ )} + + {/* Filters */} +
+ {statuses.map((s) => ( + ))}
+ + {/* Table */} +
+ {loading ? ( +
{text.loading}
+ ) : ( + + )} +
+ + setPage(p)} + onPageSizeChange={(s) => { + setPageSize(s); + setPage(1); + }} + locale={locale} + isDark={isDark} + /> + + {/* Order Detail */} + {detailOrder && ( + setDetailOrder(null)} dark={isDark} locale={locale} /> + )} ); } -function AdminOverviewFallback() { +function AdminPageFallback() { const searchParams = useSearchParams(); const locale = resolveLocale(searchParams.get('lang')); @@ -156,8 +342,8 @@ function AdminOverviewFallback() { export default function AdminPage() { return ( - }> - + }> + ); } diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx index e5573da..a289327 100644 --- a/src/app/admin/subscriptions/page.tsx +++ b/src/app/admin/subscriptions/page.tsx @@ -595,7 +595,7 @@ function SubscriptionsContent() { locale={locale} actions={ <> - + {t.orders}