revert: 去掉总览入口,/admin 直接显示订单管理
This commit is contained in:
410
.claude/plan.md
Normal file
410
.claude/plan.md
Normal file
@@ -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<Sub2ApiGroup[]>
|
||||||
|
// GET /api/v1/admin/groups/all
|
||||||
|
|
||||||
|
// 获取单个分组
|
||||||
|
async function getGroup(groupId: number): Promise<Sub2ApiGroup | null>
|
||||||
|
// GET /api/v1/admin/groups/:id
|
||||||
|
|
||||||
|
// 分配订阅(支付成功后调用)
|
||||||
|
async function assignSubscription(userId: number, groupId: number, validityDays: number, notes?: string): Promise<Sub2ApiSubscription>
|
||||||
|
// POST /api/v1/admin/subscriptions/assign
|
||||||
|
|
||||||
|
// 获取用户的订阅列表
|
||||||
|
async function getUserSubscriptions(userId: number): Promise<Sub2ApiSubscription[]>
|
||||||
|
// GET /api/v1/admin/users/:id/subscriptions
|
||||||
|
|
||||||
|
// 延长订阅(续费)
|
||||||
|
async function extendSubscription(subscriptionId: number, days: number): Promise<void>
|
||||||
|
// 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<string | undefined> {
|
||||||
|
const dbConfig = await prisma.systemConfig.findUnique({ where: { key } })
|
||||||
|
if (dbConfig) return dbConfig.value
|
||||||
|
return process.env[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量获取(带缓存,避免频繁查DB)
|
||||||
|
async function getConfigs(keys: string[]): Promise<Record<string, string>> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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,防止重复分配
|
||||||
@@ -502,7 +502,7 @@ function ChannelsContent() {
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<a href={`/admin/orders?${navParams}`} className={btnBase}>
|
<a href={`/admin?${navParams}`} className={btnBase}>
|
||||||
{t.orders}
|
{t.orders}
|
||||||
</a>
|
</a>
|
||||||
<button type="button" onClick={fetchChannels} className={btnBase}>
|
<button type="button" onClick={fetchChannels} className={btnBase}>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ function DashboardContent() {
|
|||||||
{text.daySuffix}
|
{text.daySuffix}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<a href={`/admin/orders?${navParams}`} className={btnBase}>
|
<a href={`/admin?${navParams}`} className={btnBase}>
|
||||||
{text.orders}
|
{text.orders}
|
||||||
</a>
|
</a>
|
||||||
<button type="button" onClick={fetchData} className={btnBase}>
|
<button type="button" onClick={fetchData} className={btnBase}>
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Suspense } from 'react';
|
|||||||
import { resolveLocale } from '@/lib/locale';
|
import { resolveLocale } from '@/lib/locale';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ path: '/admin', label: { zh: '总览', en: 'Overview' } },
|
{ path: '/admin', label: { zh: '订单管理', en: 'Orders' } },
|
||||||
{ path: '/admin/orders', label: { zh: '订单管理', en: 'Orders' } },
|
|
||||||
{ path: '/admin/dashboard', label: { zh: '数据概览', en: 'Dashboard' } },
|
{ path: '/admin/dashboard', label: { zh: '数据概览', en: 'Dashboard' } },
|
||||||
{ path: '/admin/channels', label: { zh: '渠道管理', en: 'Channels' } },
|
{ path: '/admin/channels', label: { zh: '渠道管理', en: 'Channels' } },
|
||||||
{ path: '/admin/subscriptions', label: { zh: '订阅管理', en: 'Subscriptions' } },
|
{ path: '/admin/subscriptions', label: { zh: '订阅管理', en: 'Subscriptions' } },
|
||||||
|
|||||||
@@ -1,349 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
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, type Locale } from '@/lib/locale';
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
|
||||||
const locale = resolveLocale(searchParams.get('lang'));
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
const isEmbedded = uiMode === 'embedded';
|
|
||||||
|
|
||||||
const text =
|
|
||||||
locale === 'en'
|
|
||||||
? {
|
|
||||||
missingToken: 'Missing admin token',
|
|
||||||
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
|
||||||
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 平台正确访问管理页面',
|
|
||||||
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<AdminOrder[]>([]);
|
|
||||||
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<AdminOrderDetail | null>(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 (
|
|
||||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
|
||||||
<div className="text-center text-red-500">
|
|
||||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
|
||||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, string> = 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 (
|
|
||||||
<PayPageLayout
|
|
||||||
isDark={isDark}
|
|
||||||
isEmbedded={isEmbedded}
|
|
||||||
maxWidth="full"
|
|
||||||
title={text.title}
|
|
||||||
subtitle={text.subtitle}
|
|
||||||
locale={locale}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
|
|
||||||
{text.dashboard}
|
|
||||||
</a>
|
|
||||||
<button type="button" onClick={fetchOrders} className={btnBase}>
|
|
||||||
{text.refresh}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="mb-4 flex flex-wrap gap-2">
|
|
||||||
{statuses.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
onClick={() => {
|
|
||||||
setStatusFilter(s);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
className={[
|
|
||||||
'rounded-full px-3 py-1 text-sm transition-colors',
|
|
||||||
statusFilter === s
|
|
||||||
? isDark
|
|
||||||
? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40'
|
|
||||||
: 'bg-blue-600 text-white'
|
|
||||||
: isDark
|
|
||||||
? 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{statusLabels[s]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'rounded-xl border',
|
|
||||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
|
|
||||||
) : (
|
|
||||||
<OrderTable
|
|
||||||
orders={orders}
|
|
||||||
onRetry={handleRetry}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
onViewDetail={handleViewDetail}
|
|
||||||
dark={isDark}
|
|
||||||
locale={locale}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PaginationBar
|
|
||||||
page={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
total={total}
|
|
||||||
pageSize={pageSize}
|
|
||||||
loading={loading}
|
|
||||||
onPageChange={(p) => setPage(p)}
|
|
||||||
onPageSizeChange={(s) => {
|
|
||||||
setPageSize(s);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
locale={locale}
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Order Detail */}
|
|
||||||
{detailOrder && (
|
|
||||||
<OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} locale={locale} />
|
|
||||||
)}
|
|
||||||
</PayPageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminPageFallback() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const locale = resolveLocale(searchParams.get('lang'));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<AdminPageFallback />}>
|
|
||||||
<AdminContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +1,49 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
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 PayPageLayout from '@/components/PayPageLayout';
|
||||||
import { resolveLocale } from '@/lib/locale';
|
import { resolveLocale, type Locale } from '@/lib/locale';
|
||||||
|
|
||||||
const MODULES = [
|
interface AdminOrder {
|
||||||
{
|
id: string;
|
||||||
path: '/admin/orders',
|
userId: number;
|
||||||
label: { zh: '订单管理', en: 'Order Management' },
|
userName: string | null;
|
||||||
desc: { zh: '查看和管理所有充值订单', en: 'View and manage all recharge orders' },
|
userEmail: string | null;
|
||||||
icon: (
|
userNotes: string | null;
|
||||||
<svg className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
amount: number;
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
status: string;
|
||||||
</svg>
|
paymentType: string;
|
||||||
),
|
createdAt: string;
|
||||||
},
|
paidAt: string | null;
|
||||||
{
|
completedAt: string | null;
|
||||||
path: '/admin/dashboard',
|
failedReason: string | null;
|
||||||
label: { zh: '数据概览', en: 'Dashboard' },
|
expiresAt: string;
|
||||||
desc: { zh: '收入统计与订单趋势', en: 'Revenue statistics and order trends' },
|
srcHost: string | null;
|
||||||
icon: (
|
}
|
||||||
<svg className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/channels',
|
|
||||||
label: { zh: '渠道管理', en: 'Channel Management' },
|
|
||||||
desc: { zh: '配置 API 渠道与倍率', en: 'Configure API channels and rate multipliers' },
|
|
||||||
icon: (
|
|
||||||
<svg className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 13.5V3.75m0 9.75a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m-3.75 0h7.5m-7.5 0H3m4.5 0a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m7.5-12a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m-3.75 0h7.5m-7.5 0H3m4.5 0a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3M18 13.5V3.75m0 9.75a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m-3.75 0h7.5M14.25 16.5H21m-4.5 0a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/subscriptions',
|
|
||||||
label: { zh: '订阅管理', en: 'Subscription Management' },
|
|
||||||
desc: { zh: '管理订阅套餐与用户订阅', en: 'Manage subscription plans and user subscriptions' },
|
|
||||||
icon: (
|
|
||||||
<svg className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 searchParams = useSearchParams();
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||||
@@ -62,16 +57,106 @@ function AdminOverviewContent() {
|
|||||||
? {
|
? {
|
||||||
missingToken: 'Missing admin token',
|
missingToken: 'Missing admin token',
|
||||||
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
||||||
title: 'Admin Panel',
|
invalidToken: 'Invalid admin token',
|
||||||
subtitle: 'Manage orders, analytics, channels and subscriptions',
|
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: '缺少管理员凭证',
|
missingToken: '缺少管理员凭证',
|
||||||
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
||||||
title: '管理后台',
|
invalidToken: '管理员凭证无效',
|
||||||
subtitle: '订单、数据、渠道与订阅的统一管理入口',
|
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<AdminOrder[]>([]);
|
||||||
|
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<AdminOrderDetail | null>(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) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||||
@@ -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<string, string> = text.statuses;
|
||||||
|
|
||||||
const navParams = new URLSearchParams();
|
const navParams = new URLSearchParams();
|
||||||
if (token) navParams.set('token', token);
|
if (token) navParams.set('token', token);
|
||||||
if (locale === 'en') navParams.set('lang', 'en');
|
if (locale === 'en') navParams.set('lang', 'en');
|
||||||
if (isDark) navParams.set('theme', 'dark');
|
if (isDark) navParams.set('theme', 'dark');
|
||||||
if (isEmbedded) navParams.set('ui_mode', 'embedded');
|
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 (
|
return (
|
||||||
<PayPageLayout isDark={isDark} isEmbedded={isEmbedded} maxWidth="full" title={text.title} subtitle={text.subtitle} locale={locale}>
|
<PayPageLayout
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
isDark={isDark}
|
||||||
{MODULES.map((mod) => (
|
isEmbedded={isEmbedded}
|
||||||
<a
|
maxWidth="full"
|
||||||
key={mod.path}
|
title={text.title}
|
||||||
href={`${mod.path}?${navParams}`}
|
subtitle={text.subtitle}
|
||||||
|
locale={locale}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
|
||||||
|
{text.dashboard}
|
||||||
|
</a>
|
||||||
|
<button type="button" onClick={fetchOrders} className={btnBase}>
|
||||||
|
{text.refresh}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(s);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
className={[
|
className={[
|
||||||
'group flex items-start gap-4 rounded-xl border p-5 transition-all',
|
'rounded-full px-3 py-1 text-sm transition-colors',
|
||||||
isDark
|
statusFilter === s
|
||||||
? 'border-slate-700 bg-slate-800/70 hover:border-indigo-500/50 hover:bg-slate-800'
|
? isDark
|
||||||
: 'border-slate-200 bg-white shadow-sm hover:border-blue-300 hover:shadow-md',
|
? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40'
|
||||||
|
: 'bg-blue-600 text-white'
|
||||||
|
: isDark
|
||||||
|
? 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div
|
{statusLabels[s]}
|
||||||
className={[
|
</button>
|
||||||
'flex h-12 w-12 shrink-0 items-center justify-center rounded-lg transition-colors',
|
|
||||||
isDark
|
|
||||||
? 'bg-slate-700 text-slate-300 group-hover:bg-indigo-500/20 group-hover:text-indigo-300'
|
|
||||||
: 'bg-slate-100 text-slate-500 group-hover:bg-blue-50 group-hover:text-blue-600',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{mod.icon}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3
|
|
||||||
className={[
|
|
||||||
'text-base font-semibold transition-colors',
|
|
||||||
isDark ? 'text-slate-100 group-hover:text-indigo-200' : 'text-slate-900 group-hover:text-blue-700',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{mod.label[locale]}
|
|
||||||
</h3>
|
|
||||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{mod.desc[locale]}</p>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className={[
|
|
||||||
'mt-1 h-5 w-5 shrink-0 transition-transform group-hover:translate-x-0.5',
|
|
||||||
isDark ? 'text-slate-600' : 'text-slate-300',
|
|
||||||
].join(' ')}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-xl border',
|
||||||
|
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
|
||||||
|
) : (
|
||||||
|
<OrderTable
|
||||||
|
orders={orders}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onViewDetail={handleViewDetail}
|
||||||
|
dark={isDark}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PaginationBar
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
total={total}
|
||||||
|
pageSize={pageSize}
|
||||||
|
loading={loading}
|
||||||
|
onPageChange={(p) => setPage(p)}
|
||||||
|
onPageSizeChange={(s) => {
|
||||||
|
setPageSize(s);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
locale={locale}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Order Detail */}
|
||||||
|
{detailOrder && (
|
||||||
|
<OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} locale={locale} />
|
||||||
|
)}
|
||||||
</PayPageLayout>
|
</PayPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminOverviewFallback() {
|
function AdminPageFallback() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const locale = resolveLocale(searchParams.get('lang'));
|
const locale = resolveLocale(searchParams.get('lang'));
|
||||||
|
|
||||||
@@ -156,8 +342,8 @@ function AdminOverviewFallback() {
|
|||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<AdminOverviewFallback />}>
|
<Suspense fallback={<AdminPageFallback />}>
|
||||||
<AdminOverviewContent />
|
<AdminContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ function SubscriptionsContent() {
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<a href={`/admin/orders?${navParams}`} className={btnBase}>
|
<a href={`/admin?${navParams}`} className={btnBase}>
|
||||||
{t.orders}
|
{t.orders}
|
||||||
</a>
|
</a>
|
||||||
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
|
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
|
||||||
|
|||||||
Reference in New Issue
Block a user