style: format all files with Prettier
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
## 一、概述
|
||||
|
||||
基于 Pincc 参考界面,改造 Sub2ApiPay 项目,新增:
|
||||
|
||||
- **用户页面**:双 Tab(按量付费 / 包月套餐),渠道卡片展示,充值弹窗,订阅购买流程
|
||||
- **管理员界面**:渠道管理、订阅套餐管理、系统配置
|
||||
- **数据库存储配置**:支付渠道等配置从环境变量迁移至数据库,支持运行时修改
|
||||
@@ -90,6 +91,7 @@ model Order {
|
||||
```
|
||||
|
||||
**设计理由**:订阅订单和余额充值订单共享同一套支付流程(创建→支付→回调→履约),仅在最终「履约」步骤不同:
|
||||
|
||||
- `balance`:调用 `createAndRedeem()` 充值余额
|
||||
- `subscription`:调用 Sub2API `POST /admin/subscriptions/assign` 分配订阅
|
||||
|
||||
@@ -101,54 +103,60 @@ model Order {
|
||||
|
||||
```typescript
|
||||
// 获取所有分组(管理员)
|
||||
async function getAllGroups(): Promise<Sub2ApiGroup[]>
|
||||
async function getAllGroups(): Promise<Sub2ApiGroup[]>;
|
||||
// GET /api/v1/admin/groups/all
|
||||
|
||||
// 获取单个分组
|
||||
async function getGroup(groupId: number): Promise<Sub2ApiGroup | null>
|
||||
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>
|
||||
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[]>
|
||||
async function getUserSubscriptions(userId: number): Promise<Sub2ApiSubscription[]>;
|
||||
// GET /api/v1/admin/users/:id/subscriptions
|
||||
|
||||
// 延长订阅(续费)
|
||||
async function extendSubscription(subscriptionId: number, days: number): Promise<void>
|
||||
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
|
||||
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
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -159,7 +167,7 @@ interface Sub2ApiSubscription {
|
||||
### 4.1 用户 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| ---- | ------------------------- | --------------------------------------------------------------- |
|
||||
| GET | `/api/channels` | 获取已启用的渠道列表(用户token验证 + 校验Sub2API分组是否存在) |
|
||||
| GET | `/api/subscription-plans` | 获取可售卖的订阅套餐列表(同上校验) |
|
||||
| POST | `/api/orders` | **扩展**:支持 `order_type: "subscription"` + `plan_id` |
|
||||
@@ -168,7 +176,7 @@ interface Sub2ApiSubscription {
|
||||
### 4.2 管理员 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| ------ | ------------------------------------ | --------------------------------- |
|
||||
| GET | `/api/admin/channels` | 渠道列表(含Sub2API分组同步状态) |
|
||||
| POST | `/api/admin/channels` | 创建/更新渠道 |
|
||||
| PUT | `/api/admin/channels/[id]` | 更新渠道 |
|
||||
@@ -191,12 +199,13 @@ interface Sub2ApiSubscription {
|
||||
```typescript
|
||||
interface CreateOrderInput {
|
||||
// 现有字段...
|
||||
orderType?: 'balance' | 'subscription' // 新增
|
||||
planId?: string // 新增(订阅时必填)
|
||||
orderType?: 'balance' | 'subscription'; // 新增
|
||||
planId?: string; // 新增(订阅时必填)
|
||||
}
|
||||
```
|
||||
|
||||
订阅订单创建时的校验逻辑:
|
||||
|
||||
1. 验证 `planId` 对应的 SubscriptionPlan 存在且 `forSale=true`
|
||||
2. 调用 Sub2API 验证 `groupId` 对应的分组仍然存在且 status=active
|
||||
3. 金额使用 plan.price(不允许用户自定义)
|
||||
@@ -259,6 +268,7 @@ if (order.orderType === 'subscription') {
|
||||
```
|
||||
|
||||
**条件逻辑**:
|
||||
|
||||
- 如果管理员 **没有配置渠道**(Channel 表为空)→ 直接显示现有的充值界面(PaymentForm),不显示卡片
|
||||
- 如果管理员 **配置了渠道** → 显示渠道卡片网格,点击"立即充值"弹出金额选择弹窗
|
||||
- 如果管理员 **没有配置订阅套餐**(SubscriptionPlan 无 forSale=true)→ 隐藏"包月套餐" Tab
|
||||
@@ -266,7 +276,7 @@ if (order.orderType === 'subscription') {
|
||||
### 6.2 新增组件
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| `ChannelCard.tsx` | 渠道卡片(平台标签、倍率、模型标签等) |
|
||||
| `ChannelGrid.tsx` | 渠道卡片网格容器 |
|
||||
| `TopUpModal.tsx` | 充值金额选择弹窗 |
|
||||
@@ -290,7 +300,7 @@ if (order.orderType === 'subscription') {
|
||||
### 7.1 页面路由
|
||||
|
||||
| 路由 | 说明 |
|
||||
|------|------|
|
||||
| ---------------------- | -------------------------------------------------- |
|
||||
| `/admin/channels` | 渠道管理(列表 + 编辑弹窗,参考 channel-conf.png) |
|
||||
| `/admin/subscriptions` | 订阅套餐管理 + 已有订阅列表 |
|
||||
| `/admin/settings` | 系统配置(支付渠道配置、业务参数等) |
|
||||
@@ -309,6 +319,7 @@ if (order.orderType === 'subscription') {
|
||||
### 7.3 订阅套餐管理页(/admin/subscriptions)
|
||||
|
||||
两个区域:
|
||||
|
||||
1. **套餐配置**:
|
||||
- 列表:套餐名 | 关联分组 | 价格 | 有效天数 | 启用售卖 | Sub2API状态 | 操作
|
||||
- 新建/编辑表单:选择 Sub2API 分组 → 配置名称、价格、原价、有效天数、特性描述、启用售卖
|
||||
@@ -320,10 +331,11 @@ if (order.orderType === 'subscription') {
|
||||
### 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_* 等
|
||||
- **显示配置**:PAY*HELP_IMAGE_URL、PAY_HELP_TEXT、PAYMENT_SUBLABEL*\* 等
|
||||
- **前端定制**:站点名称、联系客服信息等
|
||||
|
||||
配置优先级:**数据库配置 > 环境变量**(环境变量作为默认值/回退值)
|
||||
@@ -360,10 +372,12 @@ async function getConfigs(keys: string[]): Promise<Record<string, string>> { ...
|
||||
## 九、管理员入口
|
||||
|
||||
管理员通过以下方式进入:
|
||||
|
||||
1. Sub2API 管理面板中跳转(携带 admin token)
|
||||
2. 直接访问 `/admin?token=xxx`(现有机制)
|
||||
|
||||
管理员页面新增导航侧边栏:
|
||||
|
||||
- 订单管理(现有)
|
||||
- 数据概览(现有)
|
||||
- **渠道管理**(新增)
|
||||
@@ -375,27 +389,32 @@ async function getConfigs(keys: string[]): Promise<Record<string, string>> { ...
|
||||
## 十、实施顺序
|
||||
|
||||
### 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. 测试 + 端到端验证
|
||||
|
||||
|
||||
14
README.md
14
README.md
@@ -116,7 +116,7 @@ PAYMENT_PROVIDERS=easypay
|
||||
直接对接支付宝开放平台,支持 PC 页面支付(`alipay.trade.page.pay`)和手机网站支付(`alipay.trade.wap.pay`),自动根据终端类型切换。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| -------------------- | ----------------------- |
|
||||
| -------------------- | ---------------------------- |
|
||||
| `ALIPAY_APP_ID` | 支付宝应用 AppID |
|
||||
| `ALIPAY_PRIVATE_KEY` | 应用私钥(内容或文件路径) |
|
||||
| `ALIPAY_PUBLIC_KEY` | 支付宝公钥(内容或文件路径) |
|
||||
@@ -128,7 +128,7 @@ PAYMENT_PROVIDERS=easypay
|
||||
直接对接微信支付 APIv3,支持 Native 扫码支付和 H5 支付,移动端优先尝试 H5,自动 fallback 到扫码。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| ---------------------- | --------------------------- |
|
||||
| --------------------- | ------------------------------- |
|
||||
| `WXPAY_APP_ID` | 微信支付 AppID |
|
||||
| `WXPAY_MCH_ID` | 商户号 |
|
||||
| `WXPAY_PRIVATE_KEY` | 商户 API 私钥(内容或文件路径) |
|
||||
@@ -298,7 +298,7 @@ docker compose exec app npx prisma migrate deploy
|
||||
在 Sub2API 管理后台可配置以下页面链接:
|
||||
|
||||
| 页面 | 链接 | 说明 |
|
||||
| -------- | ------------------------------------ | ----------------------- |
|
||||
| -------- | ------------------------------------ | ------------------------ |
|
||||
| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 |
|
||||
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 |
|
||||
| 管理后台 | `https://pay.example.com/admin` | 管理后台入口(仅管理员) |
|
||||
@@ -320,7 +320,7 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
|
||||
访问:`https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
|
||||
|
||||
| 模块 | 路径 | 说明 |
|
||||
| -------- | ---------------------- | ------------------------------------------- |
|
||||
| -------- | ---------------------- | ---------------------------------------------- |
|
||||
| 总览 | `/admin` | 聚合入口,卡片式导航到各管理模块 |
|
||||
| 订单管理 | `/admin/orders` | 按状态筛选、分页浏览、订单详情、重试/取消/退款 |
|
||||
| 数据概览 | `/admin/dashboard` | 收入统计、订单趋势、支付方式分布 |
|
||||
@@ -366,7 +366,7 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
|
||||
用户侧接口,通过 URL 参数 `user_id` + `token` 鉴权。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ---------------------------- | ---------------------------------------- |
|
||||
| ------ | ------------------------- | ------------------------------ |
|
||||
| `GET` | `/api/user` | 获取当前用户信息 |
|
||||
| `GET` | `/api/users/:id` | 获取指定用户信息 |
|
||||
| `POST` | `/api/orders` | 创建充值 / 订阅订单 |
|
||||
@@ -383,7 +383,7 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
|
||||
由支付服务商异步调用,签名验证后触发到账流程。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ---------------------------- | ---------------------------------------- |
|
||||
| ------ | ---------------------- | ---------------------------- |
|
||||
| `GET` | `/api/easy-pay/notify` | EasyPay 异步回调(GET 方式) |
|
||||
| `POST` | `/api/alipay/notify` | 支付宝直连异步回调 |
|
||||
| `POST` | `/api/wxpay/notify` | 微信支付直连异步回调 |
|
||||
@@ -394,7 +394,7 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
|
||||
需通过 `token` 参数传递 `ADMIN_TOKEN` 鉴权。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| -------- | ----------------------------------- | ---------------------------------- |
|
||||
| -------- | ----------------------------------- | -------------------------- |
|
||||
| `GET` | `/api/admin/orders` | 订单列表(分页、状态筛选) |
|
||||
| `GET` | `/api/admin/orders/:id` | 订单详情(含审计日志) |
|
||||
| `POST` | `/api/admin/orders/:id/cancel` | 管理员取消订单 |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
本项目通过直接对接 **支付宝开放平台** 实现收款,不依赖任何三方聚合支付平台。支持以下产品:
|
||||
|
||||
| 产品 | API 方法 | 场景 |
|
||||
|------|---------|------|
|
||||
| ------------ | ----------------------- | -------------------- |
|
||||
| 电脑网站支付 | `alipay.trade.page.pay` | PC 浏览器扫码 |
|
||||
| 手机网站支付 | `alipay.trade.wap.pay` | 移动端 H5 拉起支付宝 |
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
支付宝公钥模式涉及 **三把密钥**,务必区分:
|
||||
|
||||
| 密钥 | 来源 | 用途 | 对应环境变量 |
|
||||
|------|------|------|-------------|
|
||||
| -------------- | -------------------------- | ---------------- | -------------------- |
|
||||
| **应用私钥** | 你自己生成 | 对请求参数签名 | `ALIPAY_PRIVATE_KEY` |
|
||||
| **支付宝公钥** | 上传应用公钥后,支付宝返回 | 验证回调通知签名 | `ALIPAY_PUBLIC_KEY` |
|
||||
| 应用公钥 | 你自己生成 | 上传到支付宝后台 | (不配置到项目中) |
|
||||
@@ -114,7 +114,7 @@ src/app/pay/
|
||||
## 支持的 API 能力
|
||||
|
||||
| 能力 | API | 说明 |
|
||||
|------|-----|------|
|
||||
| -------- | ----------------------------------- | ---------------- |
|
||||
| 创建支付 | `alipay.trade.page.pay` / `wap.pay` | GET 跳转方式 |
|
||||
| 查询订单 | `alipay.trade.query` | 主动查询交易状态 |
|
||||
| 关闭订单 | `alipay.trade.close` | 超时关单 |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
本项目通过直接对接 **微信支付 APIv3** 实现收款。使用 **公钥模式** 验签(非平台证书模式),支持以下产品:
|
||||
|
||||
| 产品 | API | 场景 |
|
||||
|------|-----|------|
|
||||
| ----------- | ----------------------------- | -------------------------------------- |
|
||||
| Native 支付 | `/v3/pay/transactions/native` | PC 扫码支付(生成 `weixin://` 二维码) |
|
||||
| H5 支付 | `/v3/pay/transactions/h5` | 移动端浏览器拉起微信 |
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
微信支付 APIv3 公钥模式涉及 **多组密钥**:
|
||||
|
||||
| 密钥 | 来源 | 用途 | 对应环境变量 |
|
||||
|------|------|------|-------------|
|
||||
| ------------------- | ----------------- | ------------------------- | --------------------- |
|
||||
| **商户 API 私钥** | 商户后台生成/下载 | 对 API 请求签名 | `WXPAY_PRIVATE_KEY` |
|
||||
| **微信支付公钥** | 商户后台获取 | 验证异步通知签名 | `WXPAY_PUBLIC_KEY` |
|
||||
| **微信支付公钥 ID** | 与公钥配套 | 匹配通知中的 serial | `WXPAY_PUBLIC_KEY_ID` |
|
||||
@@ -147,7 +147,7 @@ src/app/api/wxpay/
|
||||
## 支持的 API 能力
|
||||
|
||||
| 能力 | API | 说明 |
|
||||
|------|-----|------|
|
||||
| ----------- | -------------------------------------------- | ------------------------------ |
|
||||
| Native 下单 | `POST /v3/pay/transactions/native` | 返回 `code_url` 用于生成二维码 |
|
||||
| H5 下单 | `POST /v3/pay/transactions/h5` | 返回 `h5_url` 拉起微信 |
|
||||
| 查询订单 | `GET /v3/pay/transactions/out-trade-no/{id}` | 主动查询交易状态 |
|
||||
@@ -173,6 +173,7 @@ src/app/api/wxpay/
|
||||
### Q: 通知验签失败
|
||||
|
||||
检查以下几点:
|
||||
|
||||
1. `WXPAY_PUBLIC_KEY` 是否是 **微信支付公钥**(不是商户公钥或平台证书)
|
||||
2. `WXPAY_PUBLIC_KEY_ID` 是否与通知 header 中的 `Wechatpay-Serial` 匹配
|
||||
3. 服务器时间是否准确(NTP 同步)
|
||||
|
||||
@@ -7,7 +7,12 @@ describe('calculatePayAmount', () => {
|
||||
{ rechargeAmount: 100, feeRate: -1, expected: '100.00', desc: 'feeRate<0 返回原金额' },
|
||||
{ rechargeAmount: 100, feeRate: 3, expected: '103.00', desc: '100 * 3% = 3.00' },
|
||||
{ rechargeAmount: 100, feeRate: 2.5, expected: '102.50', desc: '100 * 2.5% = 2.50' },
|
||||
{ rechargeAmount: 99.99, feeRate: 1, expected: '100.99', desc: '99.99 * 1% = 0.9999 → ROUND_UP → 1.00, total 100.99' },
|
||||
{
|
||||
rechargeAmount: 99.99,
|
||||
feeRate: 1,
|
||||
expected: '100.99',
|
||||
desc: '99.99 * 1% = 0.9999 → ROUND_UP → 1.00, total 100.99',
|
||||
},
|
||||
{ rechargeAmount: 10, feeRate: 3, expected: '10.30', desc: '10 * 3% = 0.30' },
|
||||
{ rechargeAmount: 1, feeRate: 1, expected: '1.01', desc: '1 * 1% = 0.01' },
|
||||
])('$desc (amount=$rechargeAmount, rate=$feeRate)', ({ rechargeAmount, feeRate, expected }) => {
|
||||
|
||||
@@ -285,7 +285,9 @@ function ChannelsContent() {
|
||||
if (c.key === 'BALANCE_PAYMENT_DISABLED') setRcBalanceEnabled(c.value !== 'true');
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const saveRechargeConfig = async () => {
|
||||
@@ -302,7 +304,12 @@ function ChannelsContent() {
|
||||
configs: [
|
||||
{ key: 'PRODUCT_NAME_PREFIX', value: rcPrefix.trim(), group: 'payment', label: '商品名前缀' },
|
||||
{ key: 'PRODUCT_NAME_SUFFIX', value: rcSuffix.trim(), group: 'payment', label: '商品名后缀' },
|
||||
{ key: 'BALANCE_PAYMENT_DISABLED', value: rcBalanceEnabled ? 'false' : 'true', group: 'payment', label: '余额充值禁用' },
|
||||
{
|
||||
key: 'BALANCE_PAYMENT_DISABLED',
|
||||
value: rcBalanceEnabled ? 'false' : 'true',
|
||||
group: 'payment',
|
||||
label: '余额充值禁用',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -633,7 +640,12 @@ function ChannelsContent() {
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t.preview}</label>
|
||||
<div className={['rounded-lg border px-3 py-2 text-sm', isDark ? 'border-slate-600 bg-slate-700 text-slate-300' : 'border-slate-300 bg-slate-50 text-slate-600'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border px-3 py-2 text-sm',
|
||||
isDark ? 'border-slate-600 bg-slate-700 text-slate-300' : 'border-slate-300 bg-slate-50 text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{`${rcPrefix.trim() || 'Sub2API'} 100 ${rcSuffix.trim() || 'CNY'}`.trim()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -687,7 +699,11 @@ function ChannelsContent() {
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className={isDark ? 'border-b border-slate-700 text-slate-400' : 'border-b border-slate-200 text-slate-500'}>
|
||||
<tr
|
||||
className={
|
||||
isDark ? 'border-b border-slate-700 text-slate-400' : 'border-b border-slate-200 text-slate-500'
|
||||
}
|
||||
>
|
||||
<th className="px-4 py-3 text-left font-medium">{t.colName}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t.colPlatform}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t.colRate}</th>
|
||||
@@ -721,14 +737,30 @@ function ChannelsContent() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{channel.groupExists ? (
|
||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${isDark ? 'bg-emerald-900/40 text-emerald-400' : 'bg-emerald-100 text-emerald-600'}`}>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${isDark ? 'bg-emerald-900/40 text-emerald-400' : 'bg-emerald-100 text-emerald-600'}`}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
) : (
|
||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${isDark ? 'bg-red-900/40 text-red-400' : 'bg-red-100 text-red-600'}`}>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${isDark ? 'bg-red-900/40 text-red-400' : 'bg-red-100 text-red-600'}`}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
@@ -1004,9 +1036,7 @@ function ChannelsContent() {
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
{group.name}
|
||||
</span>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
#{group.id}
|
||||
</span>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>#{group.id}</span>
|
||||
<PlatformBadge platform={group.platform} className="text-[10px]" />
|
||||
{alreadyImported && (
|
||||
<span className="text-[10px] text-amber-500 font-medium">{t.syncAlreadyExists}</span>
|
||||
|
||||
@@ -350,7 +350,9 @@ function SubscriptionsContent() {
|
||||
/* --- subs state --- */
|
||||
const [subsUserId, setSubsUserId] = useState('');
|
||||
const [subsKeyword, setSubsKeyword] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<{ id: number; email: string; username: string; notes?: string }[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<{ id: number; email: string; username: string; notes?: string }[]>(
|
||||
[],
|
||||
);
|
||||
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
|
||||
const [searchTimer, setSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [subs, setSubs] = useState<Sub2ApiSubscription[]>([]);
|
||||
@@ -358,7 +360,6 @@ function SubscriptionsContent() {
|
||||
const [subsLoading, setSubsLoading] = useState(false);
|
||||
const [subsSearched, setSubsSearched] = useState(false);
|
||||
|
||||
|
||||
/* --- fetch plans --- */
|
||||
const fetchPlans = useCallback(async () => {
|
||||
if (!token) return;
|
||||
@@ -373,7 +374,7 @@ function SubscriptionsContent() {
|
||||
throw new Error(t.requestFailed);
|
||||
}
|
||||
const data = await res.json();
|
||||
setPlans(Array.isArray(data) ? data : data.plans ?? []);
|
||||
setPlans(Array.isArray(data) ? data : (data.plans ?? []));
|
||||
} catch {
|
||||
setError(t.loadFailed);
|
||||
} finally {
|
||||
@@ -388,7 +389,7 @@ function SubscriptionsContent() {
|
||||
const res = await fetch(`/api/admin/sub2api/groups?token=${encodeURIComponent(token)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setGroups(Array.isArray(data) ? data : data.groups ?? []);
|
||||
setGroups(Array.isArray(data) ? data : (data.groups ?? []));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -467,9 +468,7 @@ function SubscriptionsContent() {
|
||||
product_name: formProductName.trim() || null,
|
||||
};
|
||||
try {
|
||||
const url = editingPlan
|
||||
? `/api/admin/subscription-plans/${editingPlan.id}`
|
||||
: '/api/admin/subscription-plans';
|
||||
const url = editingPlan ? `/api/admin/subscription-plans/${editingPlan.id}` : '/api/admin/subscription-plans';
|
||||
const method = editingPlan ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
@@ -729,12 +728,7 @@ function SubscriptionsContent() {
|
||||
)}
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div
|
||||
className={[
|
||||
'mb-5 flex gap-1 rounded-xl p-1',
|
||||
isDark ? 'bg-slate-800' : 'bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['mb-5 flex gap-1 rounded-xl p-1', isDark ? 'bg-slate-800' : 'bg-slate-100'].join(' ')}>
|
||||
<button type="button" className={tabCls(activeTab === 'plans')} onClick={() => setActiveTab('plans')}>
|
||||
{t.tabPlans}
|
||||
</button>
|
||||
@@ -781,15 +775,23 @@ function SubscriptionsContent() {
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
<h3
|
||||
className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{plan.name}
|
||||
</h3>
|
||||
<span
|
||||
className={[
|
||||
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
plan.groupExists
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-50 text-green-700'
|
||||
: isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-600',
|
||||
? isDark
|
||||
? 'bg-green-500/20 text-green-300'
|
||||
: 'bg-green-50 text-green-700'
|
||||
: isDark
|
||||
? 'bg-red-500/20 text-red-300'
|
||||
: 'bg-red-50 text-red-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{plan.groupExists ? t.groupExists : t.groupMissing}
|
||||
@@ -844,31 +846,50 @@ function SubscriptionsContent() {
|
||||
{/* Plan fields grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{t.colGroup}</span>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{t.colGroup}
|
||||
</span>
|
||||
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
|
||||
<span className="font-mono text-xs">{plan.groupId}</span>
|
||||
{plan.groupName && <span className={`ml-1 text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>({plan.groupName})</span>}
|
||||
{plan.groupName && (
|
||||
<span className={`ml-1 text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
({plan.groupName})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{t.colPrice}</span>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{t.colPrice}
|
||||
</span>
|
||||
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
|
||||
¥{plan.price.toFixed(2)}
|
||||
{plan.originalPrice != null && (
|
||||
<span className={`ml-1 line-through text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
<span
|
||||
className={`ml-1 line-through text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}
|
||||
>
|
||||
¥{plan.originalPrice.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{t.colValidDays}</span>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{t.colValidDays}
|
||||
</span>
|
||||
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
|
||||
{plan.validDays} {plan.validityUnit === 'month' ? t.unitMonth : plan.validityUnit === 'week' ? t.unitWeek : t.unitDay}
|
||||
{plan.validDays}{' '}
|
||||
{plan.validityUnit === 'month'
|
||||
? t.unitMonth
|
||||
: plan.validityUnit === 'week'
|
||||
? t.unitWeek
|
||||
: t.unitDay}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{t.fieldSortOrder}</span>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{t.fieldSortOrder}
|
||||
</span>
|
||||
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>{plan.sortOrder}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -876,9 +897,16 @@ function SubscriptionsContent() {
|
||||
|
||||
{/* ── Sub2API 分组信息(嵌套只读区域) ── */}
|
||||
{plan.groupExists && (
|
||||
<div className={['border-t px-4 py-3', isDark ? 'border-slate-700 bg-slate-900/40' : 'border-slate-100 bg-slate-50/80'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'border-t px-4 py-3',
|
||||
isDark ? 'border-slate-700 bg-slate-900/40' : 'border-slate-100 bg-slate-50/80',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={['text-xs font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
<span
|
||||
className={['text-xs font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}
|
||||
>
|
||||
{t.groupInfo}
|
||||
</span>
|
||||
<span className={['text-[10px]', isDark ? 'text-slate-600' : 'text-slate-400'].join(' ')}>
|
||||
@@ -889,13 +917,17 @@ function SubscriptionsContent() {
|
||||
{plan.groupPlatform && (
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.platform}</span>
|
||||
<div className="mt-0.5"><PlatformBadge platform={plan.groupPlatform} /></div>
|
||||
<div className="mt-0.5">
|
||||
<PlatformBadge platform={plan.groupPlatform} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan.groupRateMultiplier != null && (
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.rateMultiplier}</span>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{plan.groupRateMultiplier}x</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||
{plan.groupRateMultiplier}x
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -920,14 +952,30 @@ function SubscriptionsContent() {
|
||||
<>
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>/v1/messages 调度</span>
|
||||
<div className={['mt-0.5 text-xs font-medium', plan.groupAllowMessagesDispatch ? (isDark ? 'text-green-400' : 'text-green-600') : isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'mt-0.5 text-xs font-medium',
|
||||
plan.groupAllowMessagesDispatch
|
||||
? isDark
|
||||
? 'text-green-400'
|
||||
: 'text-green-600'
|
||||
: isDark
|
||||
? 'text-slate-400'
|
||||
: 'text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
{plan.groupAllowMessagesDispatch ? '已启用' : '未启用'}
|
||||
</div>
|
||||
</div>
|
||||
{plan.groupDefaultMappedModel && (
|
||||
<div className="sm:col-span-2">
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>默认模型</span>
|
||||
<div className={['mt-0.5 font-mono text-xs', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'mt-0.5 font-mono text-xs',
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{plan.groupDefaultMappedModel}
|
||||
</div>
|
||||
</div>
|
||||
@@ -960,7 +1008,9 @@ function SubscriptionsContent() {
|
||||
fetchSubs();
|
||||
}
|
||||
}}
|
||||
onFocus={() => { if (searchResults.length > 0) setSearchDropdownOpen(true); }}
|
||||
onFocus={() => {
|
||||
if (searchResults.length > 0) setSearchDropdownOpen(true);
|
||||
}}
|
||||
placeholder={t.searchUserId}
|
||||
className={inputCls}
|
||||
/>
|
||||
@@ -994,7 +1044,10 @@ function SubscriptionsContent() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchDropdownOpen(false); fetchSubs(); }}
|
||||
onClick={() => {
|
||||
setSearchDropdownOpen(false);
|
||||
fetchSubs();
|
||||
}}
|
||||
disabled={subsLoading}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
|
||||
@@ -1029,9 +1082,7 @@ function SubscriptionsContent() {
|
||||
</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{subsUser.email}</div>
|
||||
</div>
|
||||
<div className={`ml-auto text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
ID: {subsUser.id}
|
||||
</div>
|
||||
<div className={`ml-auto text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>ID: {subsUser.id}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1040,9 +1091,7 @@ function SubscriptionsContent() {
|
||||
{subsLoading ? (
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
|
||||
) : !subsSearched ? (
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{t.loading}
|
||||
</div>
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
|
||||
) : subs.length === 0 ? (
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.noSubs}</div>
|
||||
) : (
|
||||
@@ -1134,13 +1183,10 @@ function SubscriptionsContent() {
|
||||
: 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{remaining > 0
|
||||
? `${remaining} ${t.days} ${t.remaining}`
|
||||
: t.expired}
|
||||
{remaining > 0 ? `${remaining} ${t.days} ${t.remaining}` : t.expired}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -1160,12 +1206,7 @@ function SubscriptionsContent() {
|
||||
isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<h2
|
||||
className={[
|
||||
'mb-5 text-lg font-semibold',
|
||||
isDark ? 'text-slate-100' : 'text-slate-900',
|
||||
].join(' ')}
|
||||
>
|
||||
<h2 className={['mb-5 text-lg font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{editingPlan ? t.editPlan : t.newPlan}
|
||||
</h2>
|
||||
|
||||
@@ -1173,11 +1214,7 @@ function SubscriptionsContent() {
|
||||
{/* Group */}
|
||||
<div>
|
||||
<label className={labelCls}>{t.fieldGroup}</label>
|
||||
<select
|
||||
value={formGroupId}
|
||||
onChange={(e) => setFormGroupId(e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<select value={formGroupId} onChange={(e) => setFormGroupId(e.target.value)} className={inputCls}>
|
||||
<option value="">{t.fieldGroupPlaceholder}</option>
|
||||
{availableGroups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
@@ -1198,7 +1235,12 @@ function SubscriptionsContent() {
|
||||
const selectedGroup = groups.find((g) => String(g.id) === formGroupId);
|
||||
if (!selectedGroup) return null;
|
||||
return (
|
||||
<div className={['rounded-lg border p-3 text-xs', isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border p-3 text-xs',
|
||||
isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{t.groupInfo}
|
||||
@@ -1211,13 +1253,17 @@ function SubscriptionsContent() {
|
||||
{selectedGroup.platform && (
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.platform}</span>
|
||||
<div className="mt-0.5"><PlatformBadge platform={selectedGroup.platform} /></div>
|
||||
<div className="mt-0.5">
|
||||
<PlatformBadge platform={selectedGroup.platform} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedGroup.rate_multiplier != null && (
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.rateMultiplier}</span>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{selectedGroup.rate_multiplier}x</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||
{selectedGroup.rate_multiplier}x
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -1235,13 +1281,26 @@ function SubscriptionsContent() {
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.monthlyLimit}</span>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||
{selectedGroup.monthly_limit_usd != null ? `$${selectedGroup.monthly_limit_usd}` : t.unlimited}
|
||||
{selectedGroup.monthly_limit_usd != null
|
||||
? `$${selectedGroup.monthly_limit_usd}`
|
||||
: t.unlimited}
|
||||
</div>
|
||||
</div>
|
||||
{selectedGroup.platform?.toLowerCase() === 'openai' && (
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>/v1/messages 调度</span>
|
||||
<div className={['mt-0.5 font-medium', selectedGroup.allow_messages_dispatch ? (isDark ? 'text-green-400' : 'text-green-600') : isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'mt-0.5 font-medium',
|
||||
selectedGroup.allow_messages_dispatch
|
||||
? isDark
|
||||
? 'text-green-400'
|
||||
: 'text-green-600'
|
||||
: isDark
|
||||
? 'text-slate-400'
|
||||
: 'text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
{selectedGroup.allow_messages_dispatch ? '已启用' : '未启用'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
if (body.price !== undefined && (typeof body.price !== 'number' || body.price <= 0 || body.price > 99999999.99)) {
|
||||
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||
}
|
||||
if (body.original_price !== undefined && body.original_price !== null && (typeof body.original_price !== 'number' || body.original_price <= 0 || body.original_price > 99999999.99)) {
|
||||
if (
|
||||
body.original_price !== undefined &&
|
||||
body.original_price !== null &&
|
||||
(typeof body.original_price !== 'number' || body.original_price <= 0 || body.original_price > 99999999.99)
|
||||
) {
|
||||
return NextResponse.json({ error: 'original_price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||
}
|
||||
if (body.validity_days !== undefined && (!Number.isInteger(body.validity_days) || body.validity_days <= 0)) {
|
||||
@@ -100,10 +104,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
||||
});
|
||||
|
||||
if (activeOrderCount > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `该套餐仍有 ${activeOrderCount} 个活跃订单,无法删除` },
|
||||
{ status: 409 },
|
||||
);
|
||||
return NextResponse.json({ error: `该套餐仍有 ${activeOrderCount} 个活跃订单,无法删除` }, { status: 409 });
|
||||
}
|
||||
|
||||
await prisma.subscriptionPlan.delete({ where: { id } });
|
||||
|
||||
@@ -65,7 +65,19 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { group_id, name, description, price, original_price, validity_days, validity_unit, features, for_sale, sort_order, product_name } = body;
|
||||
const {
|
||||
group_id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
original_price,
|
||||
validity_days,
|
||||
validity_unit,
|
||||
features,
|
||||
for_sale,
|
||||
sort_order,
|
||||
product_name,
|
||||
} = body;
|
||||
|
||||
if (!group_id || !name || price === undefined) {
|
||||
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
|
||||
@@ -74,7 +86,11 @@ export async function POST(request: NextRequest) {
|
||||
if (typeof price !== 'number' || price <= 0 || price > 99999999.99) {
|
||||
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||
}
|
||||
if (original_price !== undefined && original_price !== null && (typeof original_price !== 'number' || original_price <= 0 || original_price > 99999999.99)) {
|
||||
if (
|
||||
original_price !== undefined &&
|
||||
original_price !== null &&
|
||||
(typeof original_price !== 'number' || original_price <= 0 || original_price > 99999999.99)
|
||||
) {
|
||||
return NextResponse.json({ error: 'original_price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||
}
|
||||
if (validity_days !== undefined && (!Number.isInteger(validity_days) || validity_days <= 0)) {
|
||||
@@ -90,10 +106,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: `分组 ID ${group_id} 已被套餐「${existing.name}」使用` },
|
||||
{ status: 409 },
|
||||
);
|
||||
return NextResponse.json({ error: `分组 ID ${group_id} 已被套餐「${existing.name}」使用` }, { status: 409 });
|
||||
}
|
||||
|
||||
const plan = await prisma.subscriptionPlan.create({
|
||||
|
||||
@@ -25,9 +25,7 @@ export async function GET(request: NextRequest) {
|
||||
getUser(parsedUserId).catch(() => null),
|
||||
]);
|
||||
|
||||
const filtered = groupId
|
||||
? subscriptions.filter((s) => s.group_id === Number(groupId))
|
||||
: subscriptions;
|
||||
const filtered = groupId ? subscriptions.filter((s) => s.group_id === Number(groupId)) : subscriptions;
|
||||
|
||||
return NextResponse.json({
|
||||
subscriptions: filtered,
|
||||
|
||||
@@ -25,7 +25,11 @@ export async function GET(request: NextRequest) {
|
||||
plans.map(async (plan) => {
|
||||
let groupActive = false;
|
||||
let group: Awaited<ReturnType<typeof getGroup>> = null;
|
||||
let groupInfo: { daily_limit_usd: number | null; weekly_limit_usd: number | null; monthly_limit_usd: number | null } | null = null;
|
||||
let groupInfo: {
|
||||
daily_limit_usd: number | null;
|
||||
weekly_limit_usd: number | null;
|
||||
monthly_limit_usd: number | null;
|
||||
} | null = null;
|
||||
try {
|
||||
group = await getGroup(plan.groupId);
|
||||
groupActive = group !== null && group.status === 'active';
|
||||
|
||||
@@ -16,10 +16,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const [subscriptions, groups] = await Promise.all([
|
||||
getUserSubscriptions(userId),
|
||||
getAllGroups().catch(() => []),
|
||||
]);
|
||||
const [subscriptions, groups] = await Promise.all([getUserSubscriptions(userId), getAllGroups().catch(() => [])]);
|
||||
|
||||
const groupMap = new Map(groups.map((g) => [g.id, g]));
|
||||
|
||||
|
||||
@@ -242,7 +242,9 @@ function OrdersPageFallback() {
|
||||
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,19 +102,28 @@ function PayContent() {
|
||||
const renderHelpSection = () => {
|
||||
if (!hasHelpContent) return null;
|
||||
return (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'mt-6 rounded-2xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '帮助', 'Support')}
|
||||
</div>
|
||||
{helpImageUrl && (
|
||||
<img src={helpImageUrl} alt="help" onClick={() => setHelpImageOpen(true)} className={`mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain p-2 ${isDark ? 'bg-slate-700/50' : 'bg-white/70'}`} />
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt="help"
|
||||
onClick={() => setHelpImageOpen(true)}
|
||||
className={`mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain p-2 ${isDark ? 'bg-slate-700/50' : 'bg-white/70'}`}
|
||||
/>
|
||||
)}
|
||||
{helpText && (
|
||||
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{helpText.split('\n').map((line, i) => (<p key={i}>{line}</p>))}
|
||||
{helpText.split('\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,7 +242,8 @@ function PayContent() {
|
||||
const subData = await subRes.json();
|
||||
setUserSubscriptions(subData.subscriptions ?? []);
|
||||
}
|
||||
} catch {} finally {
|
||||
} catch {
|
||||
} finally {
|
||||
setChannelsLoaded(true);
|
||||
}
|
||||
}, [token]);
|
||||
@@ -282,10 +292,7 @@ function PayContent() {
|
||||
// 检查订单完成后是否是订阅分组消失的情况
|
||||
useEffect(() => {
|
||||
if (step !== 'result' || !finalOrderState) return;
|
||||
if (
|
||||
finalOrderState.status === 'FAILED' &&
|
||||
finalOrderState.failedReason?.includes('SUBSCRIPTION_GROUP_GONE')
|
||||
) {
|
||||
if (finalOrderState.status === 'FAILED' && finalOrderState.failedReason?.includes('SUBSCRIPTION_GROUP_GONE')) {
|
||||
setSubscriptionError(
|
||||
pickLocaleText(
|
||||
locale,
|
||||
@@ -302,7 +309,11 @@ function PayContent() {
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{pickLocaleText(locale, '请从 Sub2API 平台正确访问充值页面', 'Please open the recharge page from the Sub2API platform')}
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'请从 Sub2API 平台正确访问充值页面',
|
||||
'Please open the recharge page from the Sub2API platform',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,7 +326,11 @@ function PayContent() {
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{pickLocaleText(locale, '请检查链接是否正确,或联系管理员', 'Please check whether the link is correct or contact the administrator')}
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'请检查链接是否正确,或联系管理员',
|
||||
'Please check whether the link is correct or contact the administrator',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,7 +395,9 @@ function PayContent() {
|
||||
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
|
||||
PAYMENT_GATEWAY_ERROR: data.error,
|
||||
};
|
||||
setError(codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'));
|
||||
setError(
|
||||
codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -492,10 +509,15 @@ function PayContent() {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { loadUserAndOrders(); loadChannelsAndPlans(); }}
|
||||
onClick={() => {
|
||||
loadUserAndOrders();
|
||||
loadChannelsAndPlans();
|
||||
}}
|
||||
className={[
|
||||
'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',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '刷新', 'Refresh')}
|
||||
@@ -504,7 +526,9 @@ function PayContent() {
|
||||
href={ordersUrl}
|
||||
className={[
|
||||
'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',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||
@@ -515,10 +539,12 @@ function PayContent() {
|
||||
>
|
||||
{/* 订阅分组消失的常驻错误 */}
|
||||
{subscriptionError && (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'mb-4 rounded-lg border-2 p-4 text-sm',
|
||||
isDark ? 'border-red-600 bg-red-900/40 text-red-300' : 'border-red-400 bg-red-50 text-red-700',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="font-semibold mb-1">{pickLocaleText(locale, '订阅开通失败', 'Subscription Failed')}</div>
|
||||
<div>{subscriptionError}</div>
|
||||
{orderResult && (
|
||||
@@ -530,10 +556,12 @@ function PayContent() {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'mb-4 rounded-lg border p-3 text-sm',
|
||||
isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -543,18 +571,24 @@ function PayContent() {
|
||||
<>
|
||||
{/* 移动端 Tab:充值/订单 */}
|
||||
{isMobile && (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'mb-4 grid grid-cols-2 rounded-xl border p-1',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-300 bg-slate-100/90',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMobileTab('pay')}
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'pay'
|
||||
? isDark ? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm' : 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '充值', 'Recharge')}
|
||||
@@ -565,8 +599,12 @@ function PayContent() {
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'orders'
|
||||
? isDark ? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm' : 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||
@@ -586,12 +624,20 @@ function PayContent() {
|
||||
|
||||
{/* R7: 所有入口关闭提示 */}
|
||||
{allEntriesClosed && (activeMobileTab === 'pay' || !isMobile) && (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-8 text-center',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-4xl mb-4'].join(' ')}>
|
||||
<svg className={['mx-auto h-12 w-12', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg
|
||||
className={['mx-auto h-12 w-12', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||
</svg>
|
||||
@@ -600,36 +646,66 @@ function PayContent() {
|
||||
{pickLocaleText(locale, '充值/订阅 入口未开放', 'Recharge / Subscription entry is not available')}
|
||||
</p>
|
||||
<p className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '如有疑问,请联系管理员', 'Please contact the administrator if you have questions')}
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'如有疑问,请联系管理员',
|
||||
'Please contact the administrator if you have questions',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 有渠道配置:新版UI ── */}
|
||||
{channelsLoaded && showMainTabs && (activeMobileTab === 'pay' || !isMobile) && !selectedPlan && !showTopUpForm && (
|
||||
{channelsLoaded &&
|
||||
showMainTabs &&
|
||||
(activeMobileTab === 'pay' || !isMobile) &&
|
||||
!selectedPlan &&
|
||||
!showTopUpForm && (
|
||||
<>
|
||||
<MainTabs activeTab={!canTopUp ? 'subscribe' : mainTab} onTabChange={setMainTab} showSubscribeTab={hasPlans} showTopUpTab={canTopUp} isDark={isDark} locale={locale} />
|
||||
<MainTabs
|
||||
activeTab={!canTopUp ? 'subscribe' : mainTab}
|
||||
onTabChange={setMainTab}
|
||||
showSubscribeTab={hasPlans}
|
||||
showTopUpTab={canTopUp}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
{mainTab === 'topup' && canTopUp && (
|
||||
<div className="mt-6">
|
||||
{/* 按量付费说明 banner */}
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'mb-6 rounded-2xl border p-6',
|
||||
isDark
|
||||
? 'border-emerald-500/20 bg-gradient-to-r from-emerald-500/10 to-purple-500/10'
|
||||
: 'border-emerald-500/20 bg-gradient-to-r from-emerald-50 to-purple-50',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'flex-shrink-0 rounded-lg p-2',
|
||||
isDark ? 'bg-emerald-500/20' : 'bg-emerald-500/15',
|
||||
].join(' ')}>
|
||||
<svg className="h-6 w-6 text-emerald-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
].join(' ')}
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6 text-emerald-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={['text-lg font-semibold mb-2', isDark ? 'text-emerald-400' : 'text-emerald-700'].join(' ')}>
|
||||
<h3
|
||||
className={[
|
||||
'text-lg font-semibold mb-2',
|
||||
isDark ? 'text-emerald-400' : 'text-emerald-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '按量付费模式', 'Pay-as-you-go')}
|
||||
</h3>
|
||||
<p className={['text-sm mb-4', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
@@ -640,18 +716,44 @@ function PayContent() {
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
<svg className="h-4 w-4 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<div
|
||||
className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-green-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
|
||||
<polyline points="17 6 23 6 23 12" />
|
||||
</svg>
|
||||
<span>{pickLocaleText(locale, '倍率越低越划算', 'Lower rate = better value')}</span>
|
||||
</div>
|
||||
<div className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
<svg className="h-4 w-4 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<div
|
||||
className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-blue-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<span>{pickLocaleText(locale, '0.15倍率 = 1元可用约6.67美元额度', '0.15 rate = 1 CNY ≈ $6.67 quota')}</span>
|
||||
<span>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'0.15倍率 = 1元可用约6.67美元额度',
|
||||
'0.15 rate = 1 CNY ≈ $6.67 quota',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -709,7 +811,9 @@ function PayContent() {
|
||||
{/* 用户已有订阅 — 所有 tab 共用 */}
|
||||
{userSubscriptions.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className={['text-lg font-semibold mb-3', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
<h3
|
||||
className={['text-lg font-semibold mb-3', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '我的订阅', 'My Subscriptions')}
|
||||
</h3>
|
||||
<UserSubscriptions
|
||||
@@ -834,29 +938,60 @@ function PayContent() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
|
||||
</div>
|
||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<li>{pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically')}</li>
|
||||
<li>{pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for history')}</li>
|
||||
<ul
|
||||
className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}
|
||||
>
|
||||
<li>
|
||||
{pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically')}
|
||||
</li>
|
||||
<li>
|
||||
{pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for history')}
|
||||
</li>
|
||||
{config.maxDailyAmount > 0 && (
|
||||
<li>{pickLocaleText(locale, '每日最大充值', 'Max daily recharge')} ¥{config.maxDailyAmount.toFixed(2)}</li>
|
||||
<li>
|
||||
{pickLocaleText(locale, '每日最大充值', 'Max daily recharge')} ¥
|
||||
{config.maxDailyAmount.toFixed(2)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{hasHelpContent && (
|
||||
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '帮助', 'Support')}
|
||||
</div>
|
||||
{helpImageUrl && (
|
||||
<img src={helpImageUrl} alt="help" onClick={() => setHelpImageOpen(true)} className={`mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain p-2 ${isDark ? 'bg-slate-700/50' : 'bg-white/70'}`} />
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt="help"
|
||||
onClick={() => setHelpImageOpen(true)}
|
||||
className={`mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain p-2 ${isDark ? 'bg-slate-700/50' : 'bg-white/70'}`}
|
||||
/>
|
||||
)}
|
||||
{helpText && (
|
||||
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{helpText.split('\n').map((line, i) => (<p key={i}>{line}</p>))}
|
||||
<div
|
||||
className={[
|
||||
'mt-3 space-y-1 text-sm leading-6',
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{helpText.split('\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -924,8 +1059,16 @@ function PayContent() {
|
||||
|
||||
{/* 帮助图片放大 */}
|
||||
{helpImageOpen && helpImageUrl && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm" onClick={() => setHelpImageOpen(false)}>
|
||||
<img src={helpImageUrl} alt="help" className="max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl" onClick={(e) => e.stopPropagation()} />
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
||||
onClick={() => setHelpImageOpen(false)}
|
||||
>
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt="help"
|
||||
className="max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PayPageLayout>
|
||||
@@ -938,7 +1081,9 @@ function PayPageFallback() {
|
||||
const isDark = searchParams.get('theme') === 'dark';
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,12 @@ function closeCurrentWindow() {
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale, hasAccessToken: boolean, isDark = false) {
|
||||
function getStatusConfig(
|
||||
order: PublicOrderStatusSnapshot | null,
|
||||
locale: Locale,
|
||||
hasAccessToken: boolean,
|
||||
isDark = false,
|
||||
) {
|
||||
if (!order) {
|
||||
return locale === 'en'
|
||||
? {
|
||||
@@ -81,7 +86,12 @@ function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale
|
||||
icon: '✓',
|
||||
message: 'Your balance has been credited successfully.',
|
||||
}
|
||||
: { label: '充值成功', color: isDark ? 'text-green-400' : 'text-green-600', icon: '✓', message: '余额已成功到账!' };
|
||||
: {
|
||||
label: '充值成功',
|
||||
color: isDark ? 'text-green-400' : 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已成功到账!',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.paymentSuccess) {
|
||||
@@ -93,7 +103,12 @@ function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale
|
||||
icon: '⟳',
|
||||
message: 'Payment succeeded, and the balance top-up is being processed.',
|
||||
}
|
||||
: { label: '充值处理中', color: isDark ? 'text-blue-400' : 'text-blue-600', icon: '⟳', message: '支付成功,余额正在充值中...' };
|
||||
: {
|
||||
label: '充值处理中',
|
||||
color: isDark ? 'text-blue-400' : 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,余额正在充值中...',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.rechargeStatus === 'failed') {
|
||||
@@ -116,8 +131,18 @@ function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale
|
||||
|
||||
if (order.status === 'PENDING') {
|
||||
return locale === 'en'
|
||||
? { label: 'Awaiting Payment', color: isDark ? 'text-yellow-400' : 'text-yellow-600', icon: '⏳', message: 'The order has not been paid yet.' }
|
||||
: { label: '等待支付', color: isDark ? 'text-yellow-400' : 'text-yellow-600', icon: '⏳', message: '订单尚未完成支付。' };
|
||||
? {
|
||||
label: 'Awaiting Payment',
|
||||
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: 'The order has not been paid yet.',
|
||||
}
|
||||
: {
|
||||
label: '等待支付',
|
||||
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: '订单尚未完成支付。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'EXPIRED') {
|
||||
@@ -128,17 +153,37 @@ function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale
|
||||
icon: '⏰',
|
||||
message: 'This order has expired. Please create a new order.',
|
||||
}
|
||||
: { label: '订单已超时', color: isDark ? 'text-slate-400' : 'text-gray-500', icon: '⏰', message: '订单已超时,请重新充值。' };
|
||||
: {
|
||||
label: '订单已超时',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新充值。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'CANCELLED') {
|
||||
return locale === 'en'
|
||||
? { label: 'Order Cancelled', color: isDark ? 'text-slate-400' : 'text-gray-500', icon: '✗', message: 'This order has been cancelled.' }
|
||||
: { label: '订单已取消', color: isDark ? 'text-slate-400' : 'text-gray-500', icon: '✗', message: '订单已被取消。' };
|
||||
? {
|
||||
label: 'Order Cancelled',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: 'This order has been cancelled.',
|
||||
}
|
||||
: {
|
||||
label: '订单已取消',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已被取消。',
|
||||
};
|
||||
}
|
||||
|
||||
return locale === 'en'
|
||||
? { label: 'Payment Error', color: isDark ? 'text-red-400' : 'text-red-600', icon: '✗', message: 'Please contact the administrator.' }
|
||||
? {
|
||||
label: 'Payment Error',
|
||||
color: isDark ? 'text-red-400' : 'text-red-600',
|
||||
icon: '✗',
|
||||
message: 'Please contact the administrator.',
|
||||
}
|
||||
: { label: '支付异常', color: isDark ? 'text-red-400' : 'text-red-600', icon: '✗', message: '请联系管理员处理。' };
|
||||
}
|
||||
|
||||
@@ -261,7 +306,11 @@ function ResultContent() {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<button type="button" onClick={goBack} className={`mt-4 text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className={`mt-4 text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
{text.back}
|
||||
</button>
|
||||
)}
|
||||
@@ -281,7 +330,9 @@ function ResultPageFallback() {
|
||||
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,7 +281,9 @@ function StripePopupContent() {
|
||||
className={[
|
||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||
stripeSubmitting
|
||||
? isDark ? 'bg-slate-700 text-slate-400 cursor-not-allowed' : 'bg-gray-400 cursor-not-allowed'
|
||||
? isDark
|
||||
? 'bg-slate-700 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
: getPaymentMeta('stripe').buttonClass,
|
||||
].join(' ')}
|
||||
>
|
||||
@@ -308,7 +310,9 @@ function StripePopupFallback() {
|
||||
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,8 +61,12 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale }: Channe
|
||||
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
<>1元可用约<span className={['font-medium', accentCls].join(' ')}>{usableQuota}</span>美元额度</>,
|
||||
<>1 CNY ≈ <span className={['font-medium', accentCls].join(' ')}>{usableQuota}</span> USD quota</>,
|
||||
<>
|
||||
1元可用约<span className={['font-medium', accentCls].join(' ')}>{usableQuota}</span>美元额度
|
||||
</>,
|
||||
<>
|
||||
1 CNY ≈ <span className={['font-medium', accentCls].join(' ')}>{usableQuota}</span> USD quota
|
||||
</>,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -85,10 +89,7 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale }: Channe
|
||||
{channel.models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs',
|
||||
tagCls,
|
||||
].join(' ')}
|
||||
className={['inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs', tagCls].join(' ')}
|
||||
>
|
||||
<span className={['h-1.5 w-1.5 rounded-full', ps.modelTag.dot].join(' ')} />
|
||||
{model}
|
||||
|
||||
@@ -13,7 +13,14 @@ interface MainTabsProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, showTopUpTab = true, isDark, locale }: MainTabsProps) {
|
||||
export default function MainTabs({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
showSubscribeTab,
|
||||
showTopUpTab = true,
|
||||
isDark,
|
||||
locale,
|
||||
}: MainTabsProps) {
|
||||
if (!showSubscribeTab) return null;
|
||||
|
||||
const tabs: { key: 'topup' | 'subscribe'; label: string }[] = [];
|
||||
@@ -26,12 +33,7 @@ export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, sho
|
||||
if (tabs.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'inline-flex rounded-xl p-1',
|
||||
isDark ? 'bg-slate-900' : 'bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['inline-flex rounded-xl p-1', isDark ? 'bg-slate-900' : 'bg-slate-100'].join(' ')}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.key;
|
||||
return (
|
||||
|
||||
@@ -24,7 +24,12 @@ function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale, isDar
|
||||
icon: '✓',
|
||||
message: 'Your balance has been credited. Thank you for your payment.',
|
||||
}
|
||||
: { label: '充值成功', color: isDark ? 'text-green-400' : 'text-green-600', icon: '✓', message: '余额已到账,感谢您的充值!' };
|
||||
: {
|
||||
label: '充值成功',
|
||||
color: isDark ? 'text-green-400' : 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.paymentSuccess) {
|
||||
@@ -36,7 +41,12 @@ function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale, isDar
|
||||
icon: '⟳',
|
||||
message: 'Payment received. Recharging your balance...',
|
||||
}
|
||||
: { label: '充值中', color: isDark ? 'text-blue-400' : 'text-blue-600', icon: '⟳', message: '支付成功,正在充值余额中,请稍候...' };
|
||||
: {
|
||||
label: '充值中',
|
||||
color: isDark ? 'text-blue-400' : 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,正在充值余额中,请稍候...',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.rechargeStatus === 'failed') {
|
||||
@@ -77,8 +87,18 @@ function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale, isDar
|
||||
|
||||
if (order.status === 'PENDING') {
|
||||
return locale === 'en'
|
||||
? { label: 'Awaiting Payment', color: isDark ? 'text-yellow-400' : 'text-yellow-600', icon: '⏳', message: 'The order has not been paid yet.' }
|
||||
: { label: '等待支付', color: isDark ? 'text-yellow-400' : 'text-yellow-600', icon: '⏳', message: '订单尚未完成支付。' };
|
||||
? {
|
||||
label: 'Awaiting Payment',
|
||||
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: 'The order has not been paid yet.',
|
||||
}
|
||||
: {
|
||||
label: '等待支付',
|
||||
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: '订单尚未完成支付。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'EXPIRED') {
|
||||
@@ -89,12 +109,22 @@ function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale, isDar
|
||||
icon: '⏰',
|
||||
message: 'This order has expired. Please create a new one.',
|
||||
}
|
||||
: { label: '订单超时', color: isDark ? 'text-slate-400' : 'text-gray-500', icon: '⏰', message: '订单已超时,请重新创建订单。' };
|
||||
: {
|
||||
label: '订单超时',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'CANCELLED') {
|
||||
return locale === 'en'
|
||||
? { label: 'Cancelled', color: isDark ? 'text-slate-400' : 'text-gray-500', icon: '✗', message: 'The order has been cancelled.' }
|
||||
? {
|
||||
label: 'Cancelled',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: 'The order has been cancelled.',
|
||||
}
|
||||
: { label: '已取消', color: isDark ? 'text-slate-400' : 'text-gray-500', icon: '✗', message: '订单已取消。' };
|
||||
}
|
||||
|
||||
@@ -105,7 +135,12 @@ function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale, isDar
|
||||
icon: '✗',
|
||||
message: 'Payment status is abnormal. Please contact the administrator.',
|
||||
}
|
||||
: { label: '支付异常', color: isDark ? 'text-red-400' : 'text-red-600', icon: '✗', message: '支付状态异常,请联系管理员处理。' };
|
||||
: {
|
||||
label: '支付异常',
|
||||
color: isDark ? 'text-red-400' : 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '支付状态异常,请联系管理员处理。',
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrderStatus({
|
||||
|
||||
@@ -170,10 +170,12 @@ export default function PaymentForm({
|
||||
</div>
|
||||
|
||||
{fixedAmount ? (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-4 text-center',
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
||||
</div>
|
||||
@@ -240,7 +242,8 @@ export default function PaymentForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
{!fixedAmount && customAmount !== '' &&
|
||||
{!fixedAmount &&
|
||||
customAmount !== '' &&
|
||||
!isValid &&
|
||||
(() => {
|
||||
const num = parseFloat(customAmount);
|
||||
|
||||
@@ -468,10 +468,12 @@ export default function PaymentQRCode({
|
||||
].join(' ')}
|
||||
/>
|
||||
{stripeError && (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border p-3 text-sm',
|
||||
dark ? 'border-red-700/50 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,11 @@ const STEPS: Step[] = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
zh: '选择套餐',
|
||||
@@ -28,7 +32,11 @@ const STEPS: Step[] = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
zh: '完成支付',
|
||||
@@ -37,7 +45,11 @@ const STEPS: Step[] = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
zh: '获取激活码',
|
||||
@@ -87,12 +99,7 @@ export default function PurchaseFlow({ isDark, locale }: PurchaseFlowProps) {
|
||||
|
||||
{/* Connector */}
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div
|
||||
className={[
|
||||
'mx-4 h-px w-12 flex-shrink-0',
|
||||
isDark ? 'bg-slate-700' : 'bg-slate-300',
|
||||
].join(' ')}
|
||||
/>
|
||||
<div className={['mx-4 h-px w-12 flex-shrink-0', isDark ? 'bg-slate-700' : 'bg-slate-300'].join(' ')} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -119,12 +126,7 @@ export default function PurchaseFlow({ isDark, locale }: PurchaseFlowProps) {
|
||||
|
||||
{/* Connector */}
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div
|
||||
className={[
|
||||
'ml-5 h-6 w-px',
|
||||
isDark ? 'bg-slate-700' : 'bg-slate-300',
|
||||
].join(' ')}
|
||||
/>
|
||||
<div className={['ml-5 h-6 w-px', isDark ? 'bg-slate-700' : 'bg-slate-300'].join(' ')} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -32,11 +32,11 @@ export default function SubscriptionConfirm({
|
||||
|
||||
const periodLabel = formatValidityLabel(plan.validityDays, plan.validityUnit ?? 'day', locale);
|
||||
|
||||
const hasLimits = plan.limits && (
|
||||
plan.limits.daily_limit_usd !== null ||
|
||||
const hasLimits =
|
||||
plan.limits &&
|
||||
(plan.limits.daily_limit_usd !== null ||
|
||||
plan.limits.weekly_limit_usd !== null ||
|
||||
plan.limits.monthly_limit_usd !== null
|
||||
);
|
||||
plan.limits.monthly_limit_usd !== null);
|
||||
|
||||
const isOpenAI = plan.platform?.toLowerCase() === 'openai';
|
||||
|
||||
@@ -90,10 +90,12 @@ export default function SubscriptionConfirm({
|
||||
{periodLabel}
|
||||
</span>
|
||||
{isOpenAI && plan.allowMessagesDispatch && (
|
||||
<span className={[
|
||||
<span
|
||||
className={[
|
||||
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
/v1/messages
|
||||
</span>
|
||||
)}
|
||||
@@ -166,10 +168,12 @@ export default function SubscriptionConfirm({
|
||||
|
||||
{/* OpenAI specific: default model */}
|
||||
{isOpenAI && plan.defaultMappedModel && (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'flex items-center justify-between rounded-lg border px-3 py-2 text-sm',
|
||||
isDark ? 'border-green-500/30 bg-green-500/10' : 'border-green-200 bg-green-50/50',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
{pickLocaleText(locale, '默认模型', 'Default Model')}
|
||||
</span>
|
||||
@@ -284,9 +288,7 @@ export default function SubscriptionConfirm({
|
||||
: 'cursor-not-allowed bg-slate-200 text-slate-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{loading
|
||||
? pickLocaleText(locale, '处理中...', 'Processing...')
|
||||
: pickLocaleText(locale, '立即购买', 'Buy Now')}
|
||||
{loading ? pickLocaleText(locale, '处理中...', 'Processing...') : pickLocaleText(locale, '立即购买', 'Buy Now')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,11 +40,11 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
||||
const periodLabel = formatValidityLabel(plan.validityDays, unit, locale);
|
||||
const periodSuffix = formatValiditySuffix(plan.validityDays, unit, locale);
|
||||
|
||||
const hasLimits = plan.limits && (
|
||||
plan.limits.daily_limit_usd !== null ||
|
||||
const hasLimits =
|
||||
plan.limits &&
|
||||
(plan.limits.daily_limit_usd !== null ||
|
||||
plan.limits.weekly_limit_usd !== null ||
|
||||
plan.limits.monthly_limit_usd !== null
|
||||
);
|
||||
plan.limits.monthly_limit_usd !== null);
|
||||
|
||||
const isOpenAI = plan.platform?.toLowerCase() === 'openai';
|
||||
const ps = getPlatformStyle(plan.platform ?? '');
|
||||
@@ -61,9 +61,7 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
||||
<div className="mb-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{plan.platform && <PlatformBadge platform={plan.platform} />}
|
||||
<h3 className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
<h3 className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>{plan.name}</h3>
|
||||
<span
|
||||
className={[
|
||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
@@ -73,10 +71,12 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
||||
{periodLabel}
|
||||
</span>
|
||||
{isOpenAI && plan.allowMessagesDispatch && (
|
||||
<span className={[
|
||||
<span
|
||||
className={[
|
||||
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
/v1/messages
|
||||
</span>
|
||||
)}
|
||||
@@ -90,9 +90,7 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
||||
</span>
|
||||
)}
|
||||
<span className={['text-3xl font-bold', accentCls].join(' ')}>¥{plan.price}</span>
|
||||
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{periodSuffix}
|
||||
</span>
|
||||
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{periodSuffix}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,10 +151,12 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
||||
|
||||
{/* OpenAI specific: default model */}
|
||||
{isOpenAI && plan.defaultMappedModel && (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'mb-4 flex items-center justify-between rounded-lg border px-3 py-2 text-sm',
|
||||
isDark ? 'border-green-500/30 bg-green-500/10' : 'border-green-200 bg-green-50/50',
|
||||
].join(' ')}>
|
||||
].join(' ')}
|
||||
>
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
{pickLocaleText(locale, '默认模型', 'Default Model')}
|
||||
</span>
|
||||
|
||||
@@ -37,15 +37,15 @@ export default function TopUpModal({ open, onClose, onConfirm, amounts, isDark,
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{pickLocaleText(locale, '选择充值金额', 'Select Amount')}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold">{pickLocaleText(locale, '选择充值金额', 'Select Amount')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={[
|
||||
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
|
||||
isDark ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600',
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -82,9 +82,7 @@ export default function TopUpModal({ open, onClose, onConfirm, amounts, isDark,
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, `余额充值${amount}$`, `Balance +${amount}$`)}
|
||||
</span>
|
||||
<span className="mt-1 text-2xl font-bold text-emerald-500">
|
||||
¥{amount}
|
||||
</span>
|
||||
<span className="mt-1 text-2xl font-bold text-emerald-500">¥{amount}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -39,11 +39,26 @@ function daysUntil(iso: string): number {
|
||||
|
||||
function getStatusBadge(status: string, isDark: boolean, locale: Locale): { text: string; className: string } {
|
||||
const statusMap: Record<string, { zh: string; en: string; cls: string; clsDark: string }> = {
|
||||
active: { zh: '生效中', en: 'Active', cls: 'bg-emerald-100 text-emerald-700', clsDark: 'bg-emerald-900/40 text-emerald-300' },
|
||||
expired: { zh: '已过期', en: 'Expired', cls: 'bg-slate-100 text-slate-600', clsDark: 'bg-slate-700 text-slate-400' },
|
||||
active: {
|
||||
zh: '生效中',
|
||||
en: 'Active',
|
||||
cls: 'bg-emerald-100 text-emerald-700',
|
||||
clsDark: 'bg-emerald-900/40 text-emerald-300',
|
||||
},
|
||||
expired: {
|
||||
zh: '已过期',
|
||||
en: 'Expired',
|
||||
cls: 'bg-slate-100 text-slate-600',
|
||||
clsDark: 'bg-slate-700 text-slate-400',
|
||||
},
|
||||
cancelled: { zh: '已取消', en: 'Cancelled', cls: 'bg-red-100 text-red-700', clsDark: 'bg-red-900/40 text-red-300' },
|
||||
};
|
||||
const entry = statusMap[status] || { zh: status, en: status, cls: 'bg-slate-100 text-slate-600', clsDark: 'bg-slate-700 text-slate-400' };
|
||||
const entry = statusMap[status] || {
|
||||
zh: status,
|
||||
en: status,
|
||||
cls: 'bg-slate-100 text-slate-600',
|
||||
clsDark: 'bg-slate-700 text-slate-400',
|
||||
};
|
||||
return {
|
||||
text: pickLocaleText(locale, entry.zh, entry.en),
|
||||
className: isDark ? entry.clsDark : entry.cls,
|
||||
@@ -59,8 +74,18 @@ export default function UserSubscriptions({ subscriptions, onRenew, isDark, loca
|
||||
isDark ? 'border-slate-700 bg-slate-800/50 text-slate-400' : 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className="mb-3 h-12 w-12 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<svg
|
||||
className="mb-3 h-12 w-12 opacity-40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">{pickLocaleText(locale, '暂无订阅', 'No Subscriptions')}</p>
|
||||
</div>
|
||||
@@ -110,15 +135,31 @@ export default function UserSubscriptions({ subscriptions, onRenew, isDark, loca
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className={['mb-3 grid grid-cols-2 gap-3 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
<div
|
||||
className={['mb-3 grid grid-cols-2 gap-3 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<span className={['text-xs uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{pickLocaleText(locale, '开始', 'Start')}</span>
|
||||
<span
|
||||
className={['text-xs uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{pickLocaleText(locale, '开始', 'Start')}
|
||||
</span>
|
||||
<p className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
|
||||
{formatDate(sub.starts_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={['text-xs uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>{pickLocaleText(locale, '到期', 'Expires')}</span>
|
||||
<span
|
||||
className={['text-xs uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{pickLocaleText(locale, '到期', 'Expires')}
|
||||
</span>
|
||||
<p className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
|
||||
{formatDate(sub.expires_at)}
|
||||
</p>
|
||||
@@ -133,11 +174,7 @@ export default function UserSubscriptions({ subscriptions, onRenew, isDark, loca
|
||||
isDark ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
`即将到期,剩余 ${remaining} 天`,
|
||||
`Expiring soon, ${remaining} days remaining`,
|
||||
)}
|
||||
{pickLocaleText(locale, `即将到期,剩余 ${remaining} 天`, `Expiring soon, ${remaining} days remaining`)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -133,7 +133,9 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
</td>
|
||||
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
||||
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}>
|
||||
<td
|
||||
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
|
||||
>
|
||||
{currency}
|
||||
{order.amount.toFixed(2)}
|
||||
</td>
|
||||
|
||||
@@ -73,9 +73,7 @@ export default function RefundDialog({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className={['w-full max-w-md rounded-xl p-6 shadow-xl', dark ? 'bg-slate-900' : 'bg-white'].join(' ')}
|
||||
>
|
||||
<div className={['w-full max-w-md rounded-xl p-6 shadow-xl', dark ? 'bg-slate-900' : 'bg-white'].join(' ')}>
|
||||
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>{text.title}</h3>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
@@ -149,7 +147,9 @@ export default function RefundDialog({
|
||||
disabled={loading || (requireForce && !force)}
|
||||
className={[
|
||||
'flex-1 rounded-lg py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed',
|
||||
dark ? 'bg-red-600/90 disabled:bg-slate-700 disabled:text-slate-500' : 'bg-red-600 disabled:bg-gray-300 disabled:text-gray-400',
|
||||
dark
|
||||
? 'bg-red-600/90 disabled:bg-slate-700 disabled:text-slate-500'
|
||||
: 'bg-red-600 disabled:bg-gray-300 disabled:text-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{loading ? text.processing : text.confirm}
|
||||
|
||||
@@ -60,7 +60,15 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const orderType = input.orderType ?? 'balance';
|
||||
|
||||
// ── 订阅订单前置校验 ──
|
||||
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; validityUnit: string; name: string; productName: string | null } | null = null;
|
||||
let subscriptionPlan: {
|
||||
id: string;
|
||||
groupId: number;
|
||||
price: Prisma.Decimal;
|
||||
validityDays: number;
|
||||
validityUnit: string;
|
||||
name: string;
|
||||
productName: string | null;
|
||||
} | null = null;
|
||||
let subscriptionGroupName = '';
|
||||
|
||||
// R6: 余额充值禁用检查
|
||||
@@ -77,11 +85,19 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
|
||||
if (orderType === 'subscription') {
|
||||
if (!input.planId) {
|
||||
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
|
||||
throw new OrderError(
|
||||
'INVALID_INPUT',
|
||||
message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: input.planId } });
|
||||
if (!plan || !plan.forSale) {
|
||||
throw new OrderError('PLAN_NOT_AVAILABLE', message(locale, '该套餐不存在或未上架', 'Plan not found or not for sale'), 404);
|
||||
throw new OrderError(
|
||||
'PLAN_NOT_AVAILABLE',
|
||||
message(locale, '该套餐不存在或未上架', 'Plan not found or not for sale'),
|
||||
404,
|
||||
);
|
||||
}
|
||||
// 校验 Sub2API 分组仍然存在
|
||||
const group = await getGroup(plan.groupId);
|
||||
@@ -284,7 +300,11 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
amount: input.amount,
|
||||
paymentType: input.paymentType,
|
||||
orderType,
|
||||
...(subscriptionPlan && { planId: subscriptionPlan.id, planName: subscriptionPlan.name, groupId: subscriptionPlan.groupId }),
|
||||
...(subscriptionPlan && {
|
||||
planId: subscriptionPlan.id,
|
||||
planName: subscriptionPlan.name,
|
||||
groupId: subscriptionPlan.groupId,
|
||||
}),
|
||||
}),
|
||||
operator: `user:${input.userId}`,
|
||||
},
|
||||
@@ -683,9 +703,7 @@ export async function executeSubscriptionFulfillment(orderId: string): Promise<v
|
||||
data: {
|
||||
status: ORDER_STATUS.FAILED,
|
||||
failedAt: new Date(),
|
||||
failedReason: isGroupGone
|
||||
? `SUBSCRIPTION_GROUP_GONE: ${reason}`
|
||||
: reason,
|
||||
failedReason: isGroupGone ? `SUBSCRIPTION_GROUP_GONE: ${reason}` : reason,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,8 +20,15 @@ const PLATFORM_STYLES: Record<string, PlatformStyleEntry> = {
|
||||
border: 'border-orange-500/20',
|
||||
label: 'Claude',
|
||||
icon: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
|
||||
modelTag: { light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600', dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300', dot: 'bg-orange-500' },
|
||||
button: { light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700', dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600' },
|
||||
modelTag: {
|
||||
light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600',
|
||||
dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300',
|
||||
dot: 'bg-orange-500',
|
||||
},
|
||||
button: {
|
||||
light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700',
|
||||
dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600',
|
||||
},
|
||||
accent: { light: 'text-orange-600', dark: 'text-orange-300' },
|
||||
},
|
||||
anthropic: {
|
||||
@@ -29,16 +36,30 @@ const PLATFORM_STYLES: Record<string, PlatformStyleEntry> = {
|
||||
border: 'border-orange-500/20',
|
||||
label: 'Anthropic',
|
||||
icon: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
|
||||
modelTag: { light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600', dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300', dot: 'bg-orange-500' },
|
||||
button: { light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700', dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600' },
|
||||
modelTag: {
|
||||
light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600',
|
||||
dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300',
|
||||
dot: 'bg-orange-500',
|
||||
},
|
||||
button: {
|
||||
light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700',
|
||||
dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600',
|
||||
},
|
||||
accent: { light: 'text-orange-600', dark: 'text-orange-300' },
|
||||
},
|
||||
openai: {
|
||||
badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
|
||||
border: 'border-green-500/20',
|
||||
label: 'OpenAI',
|
||||
modelTag: { light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600', dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300', dot: 'bg-green-500' },
|
||||
button: { light: 'bg-green-600 hover:bg-green-700 active:bg-green-800', dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700' },
|
||||
modelTag: {
|
||||
light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600',
|
||||
dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300',
|
||||
dot: 'bg-green-500',
|
||||
},
|
||||
button: {
|
||||
light: 'bg-green-600 hover:bg-green-700 active:bg-green-800',
|
||||
dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700',
|
||||
},
|
||||
accent: { light: 'text-green-600', dark: 'text-green-300' },
|
||||
icon: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
|
||||
},
|
||||
@@ -46,8 +67,15 @@ const PLATFORM_STYLES: Record<string, PlatformStyleEntry> = {
|
||||
badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
|
||||
border: 'border-green-500/20',
|
||||
label: 'Codex',
|
||||
modelTag: { light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600', dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300', dot: 'bg-green-500' },
|
||||
button: { light: 'bg-green-600 hover:bg-green-700 active:bg-green-800', dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700' },
|
||||
modelTag: {
|
||||
light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600',
|
||||
dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300',
|
||||
dot: 'bg-green-500',
|
||||
},
|
||||
button: {
|
||||
light: 'bg-green-600 hover:bg-green-700 active:bg-green-800',
|
||||
dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700',
|
||||
},
|
||||
accent: { light: 'text-green-600', dark: 'text-green-300' },
|
||||
icon: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
|
||||
},
|
||||
@@ -55,8 +83,15 @@ const PLATFORM_STYLES: Record<string, PlatformStyleEntry> = {
|
||||
badge: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
||||
border: 'border-blue-500/20',
|
||||
label: 'Gemini',
|
||||
modelTag: { light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600', dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300', dot: 'bg-blue-500' },
|
||||
button: { light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700', dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600' },
|
||||
modelTag: {
|
||||
light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600',
|
||||
dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300',
|
||||
dot: 'bg-blue-500',
|
||||
},
|
||||
button: {
|
||||
light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700',
|
||||
dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600',
|
||||
},
|
||||
accent: { light: 'text-blue-600', dark: 'text-blue-300' },
|
||||
icon: 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81',
|
||||
},
|
||||
@@ -64,8 +99,15 @@ const PLATFORM_STYLES: Record<string, PlatformStyleEntry> = {
|
||||
badge: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
||||
border: 'border-blue-500/20',
|
||||
label: 'Google',
|
||||
modelTag: { light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600', dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300', dot: 'bg-blue-500' },
|
||||
button: { light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700', dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600' },
|
||||
modelTag: {
|
||||
light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600',
|
||||
dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300',
|
||||
dot: 'bg-blue-500',
|
||||
},
|
||||
button: {
|
||||
light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700',
|
||||
dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600',
|
||||
},
|
||||
accent: { light: 'text-blue-600', dark: 'text-blue-300' },
|
||||
icon: 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81',
|
||||
},
|
||||
@@ -73,8 +115,15 @@ const PLATFORM_STYLES: Record<string, PlatformStyleEntry> = {
|
||||
badge: 'bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/30',
|
||||
border: 'border-pink-500/20',
|
||||
label: 'Sora',
|
||||
modelTag: { light: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-600', dark: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-300', dot: 'bg-pink-500' },
|
||||
button: { light: 'bg-pink-500 hover:bg-pink-600 active:bg-pink-700', dark: 'bg-pink-500/80 hover:bg-pink-500 active:bg-pink-600' },
|
||||
modelTag: {
|
||||
light: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-600',
|
||||
dark: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-300',
|
||||
dot: 'bg-pink-500',
|
||||
},
|
||||
button: {
|
||||
light: 'bg-pink-500 hover:bg-pink-600 active:bg-pink-700',
|
||||
dark: 'bg-pink-500/80 hover:bg-pink-500 active:bg-pink-600',
|
||||
},
|
||||
accent: { light: 'text-pink-600', dark: 'text-pink-300' },
|
||||
// four-pointed sparkle star
|
||||
icon: 'M12 2l2.09 6.26L20.18 10l-6.09 1.74L12 18l-2.09-6.26L3.82 10l6.09-1.74L12 2z',
|
||||
@@ -83,8 +132,15 @@ const PLATFORM_STYLES: Record<string, PlatformStyleEntry> = {
|
||||
badge: 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||
border: 'border-purple-500/20',
|
||||
label: 'Antigravity',
|
||||
modelTag: { light: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-600', dark: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-300', dot: 'bg-purple-500' },
|
||||
button: { light: 'bg-purple-500 hover:bg-purple-600 active:bg-purple-700', dark: 'bg-purple-500/80 hover:bg-purple-500 active:bg-purple-600' },
|
||||
modelTag: {
|
||||
light: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-600',
|
||||
dark: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-300',
|
||||
dot: 'bg-purple-500',
|
||||
},
|
||||
button: {
|
||||
light: 'bg-purple-500 hover:bg-purple-600 active:bg-purple-700',
|
||||
dark: 'bg-purple-500/80 hover:bg-purple-500 active:bg-purple-600',
|
||||
},
|
||||
accent: { light: 'text-purple-600', dark: 'text-purple-300' },
|
||||
// stylised angular "A" cursor shape
|
||||
icon: 'M12 2L4 22h4l2-5h4l2 5h4L12 2zm0 7l2.5 6h-5L12 9z',
|
||||
@@ -96,8 +152,15 @@ const FALLBACK_STYLE: PlatformStyleEntry = {
|
||||
border: 'border-slate-500/20',
|
||||
label: '',
|
||||
icon: '',
|
||||
modelTag: { light: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-600', dark: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-400', dot: 'bg-slate-500' },
|
||||
button: { light: 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700', dark: 'bg-emerald-500/80 hover:bg-emerald-500 active:bg-emerald-600' },
|
||||
modelTag: {
|
||||
light: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-600',
|
||||
dark: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-400',
|
||||
dot: 'bg-slate-500',
|
||||
},
|
||||
button: {
|
||||
light: 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700',
|
||||
dark: 'bg-emerald-500/80 hover:bg-emerald-500 active:bg-emerald-600',
|
||||
},
|
||||
accent: { light: 'text-emerald-600', dark: 'text-emerald-300' },
|
||||
};
|
||||
|
||||
|
||||
@@ -229,7 +229,9 @@ export async function subtractBalance(
|
||||
|
||||
// ── 用户搜索 API ──
|
||||
|
||||
export async function searchUsers(keyword: string): Promise<{ id: number; email: string; username: string; notes?: string }[]> {
|
||||
export async function searchUsers(
|
||||
keyword: string,
|
||||
): Promise<{ id: number; email: string; username: string; notes?: string }[]> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(
|
||||
`${env.SUB2API_BASE_URL}/api/v1/admin/users?search=${encodeURIComponent(keyword)}&page=1&page_size=30`,
|
||||
|
||||
@@ -23,11 +23,7 @@ export function computeValidityDays(value: number, unit: ValidityUnit, fromDate?
|
||||
* - unit=week, value=2 → 2周 / 2 Weeks
|
||||
* - unit=day, value=30 → 30天 / 30 Days
|
||||
*/
|
||||
export function formatValidityLabel(
|
||||
value: number,
|
||||
unit: ValidityUnit,
|
||||
locale: 'zh' | 'en',
|
||||
): string {
|
||||
export function formatValidityLabel(value: number, unit: ValidityUnit, locale: 'zh' | 'en'): string {
|
||||
const unitLabels: Record<ValidityUnit, { zh: string; en: string; enPlural: string }> = {
|
||||
day: { zh: '天', en: 'Day', enPlural: 'Days' },
|
||||
week: { zh: '周', en: 'Week', enPlural: 'Weeks' },
|
||||
@@ -44,11 +40,7 @@ export function formatValidityLabel(
|
||||
* - unit=week, value=2 → /2周 / /2wk
|
||||
* - unit=day, value=30 → /30天 / /30d
|
||||
*/
|
||||
export function formatValiditySuffix(
|
||||
value: number,
|
||||
unit: ValidityUnit,
|
||||
locale: 'zh' | 'en',
|
||||
): string {
|
||||
export function formatValiditySuffix(value: number, unit: ValidityUnit, locale: 'zh' | 'en'): string {
|
||||
const unitLabels: Record<ValidityUnit, { zh: string; en: string }> = {
|
||||
day: { zh: '天', en: 'd' },
|
||||
week: { zh: '周', en: 'wk' },
|
||||
@@ -65,11 +57,7 @@ export function formatValiditySuffix(
|
||||
* - unit=week → "2 周"
|
||||
* - unit=month → "1 月"
|
||||
*/
|
||||
export function formatValidityDisplay(
|
||||
value: number,
|
||||
unit: ValidityUnit,
|
||||
locale: 'zh' | 'en',
|
||||
): string {
|
||||
export function formatValidityDisplay(value: number, unit: ValidityUnit, locale: 'zh' | 'en'): string {
|
||||
const unitLabels: Record<ValidityUnit, { zh: string; en: string }> = {
|
||||
day: { zh: '天', en: 'day(s)' },
|
||||
week: { zh: '周', en: 'week(s)' },
|
||||
|
||||
@@ -85,12 +85,18 @@ export async function setSystemConfig(key: string, value: string, group?: string
|
||||
invalidateConfigCache(key);
|
||||
}
|
||||
|
||||
export async function setSystemConfigs(configs: { key: string; value: string; group?: string; label?: string }[]): Promise<void> {
|
||||
export async function setSystemConfigs(
|
||||
configs: { key: string; value: string; group?: string; label?: string }[],
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(
|
||||
configs.map((c) =>
|
||||
prisma.systemConfig.upsert({
|
||||
where: { key: c.key },
|
||||
update: { value: c.value, ...(c.group !== undefined && { group: c.group }), ...(c.label !== undefined && { label: c.label }) },
|
||||
update: {
|
||||
value: c.value,
|
||||
...(c.group !== undefined && { group: c.group }),
|
||||
...(c.label !== undefined && { label: c.label }),
|
||||
},
|
||||
create: { key: c.key, value: c.value, group: c.group ?? 'general', label: c.label },
|
||||
}),
|
||||
),
|
||||
@@ -98,7 +104,9 @@ export async function setSystemConfigs(configs: { key: string; value: string; gr
|
||||
invalidateConfigCache();
|
||||
}
|
||||
|
||||
export async function getSystemConfigsByGroup(group: string): Promise<{ key: string; value: string; label: string | null }[]> {
|
||||
export async function getSystemConfigsByGroup(
|
||||
group: string,
|
||||
): Promise<{ key: string; value: string; label: string | null }[]> {
|
||||
return prisma.systemConfig.findMany({
|
||||
where: { group },
|
||||
select: { key: true, value: true, label: true },
|
||||
@@ -106,7 +114,9 @@ export async function getSystemConfigsByGroup(group: string): Promise<{ key: str
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllSystemConfigs(): Promise<{ key: string; value: string; group: string; label: string | null }[]> {
|
||||
export async function getAllSystemConfigs(): Promise<
|
||||
{ key: string; value: string; group: string; label: string | null }[]
|
||||
> {
|
||||
return prisma.systemConfig.findMany({
|
||||
select: { key: true, value: true, group: true, label: true },
|
||||
orderBy: [{ group: 'asc' }, { key: 'asc' }],
|
||||
|
||||
Reference in New Issue
Block a user