fix: 暗色主题全面优化 - 滚动条、颜色适配、dark:伪类修复
- 添加全局暗色滚动条样式 (globals.css) - 修复 channels/subscriptions 页面 dark: 伪类无效的bug (改用 isDark) - 修复 RefundDialog 暗色下退款金额、disabled 按钮颜色 - 修复 admin OrderTable 暗色下缺少背景色 - 统一所有 admin 页面 text-gray-500 为 slate 色系 - 修复 UserSubscriptions 续费按钮暗色适配 - 修复日期标签文字缺少暗色颜色类 - MainTabs 暗色容器改用 slate-900 提升对比度 - OpenAI 默认模型区域暗色边框透明度提升 - 更新 README 文档
This commit is contained in:
154
README.md
154
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
**语言 / Language**: 中文(当前)| [English](./README.en.md)
|
||||
|
||||
Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管充值支付网关。支持支付宝、微信支付(通过 EasyPay 聚合)和 Stripe,订单支付成功后自动调用 Sub2API 管理接口完成余额到账,无需人工干预。
|
||||
Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管支付网关。支持**支付宝直连**、**微信支付直连**、EasyPay 易支付聚合和 Stripe 四种支付渠道,提供按量充值与套餐订阅两种计费模式,支付成功后自动调用 Sub2API 管理接口完成到账,无需人工干预。
|
||||
|
||||
---
|
||||
|
||||
@@ -16,19 +16,22 @@ Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管充值
|
||||
- [集成到 Sub2API](#集成到-sub2api)
|
||||
- [管理后台](#管理后台)
|
||||
- [支付流程](#支付流程)
|
||||
- [API 端点](#api-端点)
|
||||
- [开发指南](#开发指南)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **多支付方式** — 支付宝、微信支付(EasyPay 聚合)、Stripe 信用卡
|
||||
- **自动到账** — 支付回调验签后自动调用 Sub2API 充值接口,全程无需人工
|
||||
- **四渠道支付** — 支付宝官方直连、微信支付官方直连、EasyPay 易支付聚合、Stripe 国际信用卡
|
||||
- **双计费模式** — 按量余额充值 + 套餐订阅,灵活适配不同业务场景
|
||||
- **自动到账** — 支付回调验签后自动调用 Sub2API 充值 / 订阅接口,全程无需人工
|
||||
- **订单全生命周期** — 超时自动取消、用户主动取消、管理员取消、退款
|
||||
- **限额控制** — 可配置单笔上限与每日累计上限,按用户维度统计
|
||||
- **安全设计** — Token 鉴权、MD5/Webhook 签名验证、时序安全对比、完整审计日志
|
||||
- **响应式 UI** — PC + 移动端自适应,支持深色模式,支持 iframe 嵌入
|
||||
- **管理后台** — 订单列表(分页/筛选)、订单详情、重试充值、退款
|
||||
- **限额控制** — 单笔上限、每日用户累计上限、每日渠道全局限额,多维度风控
|
||||
- **安全设计** — Token 鉴权、RSA2 / MD5 / Webhook 签名验证、时序安全对比、完整审计日志
|
||||
- **响应式 UI** — PC + 移动端自适应,暗色 / 亮色主题,支持 iframe 嵌入
|
||||
- **中英双语** — 支付页面自动适配中英文
|
||||
- **管理后台** — 数据概览、订单管理(分页/筛选/重试/退款)、渠道管理、订阅管理
|
||||
|
||||
---
|
||||
|
||||
@@ -99,22 +102,43 @@ docker compose up -d --build
|
||||
**第一步**:通过 `PAYMENT_PROVIDERS` 声明启用哪些支付服务商(逗号分隔):
|
||||
|
||||
```env
|
||||
# 仅易支付
|
||||
# 可选值: easypay, alipay, wxpay, stripe
|
||||
# 示例:同时启用支付宝直连 + 微信直连 + Stripe
|
||||
PAYMENT_PROVIDERS=alipay,wxpay,stripe
|
||||
# 示例:仅使用易支付聚合
|
||||
PAYMENT_PROVIDERS=easypay
|
||||
# 仅 Stripe
|
||||
PAYMENT_PROVIDERS=stripe
|
||||
# 两者都用
|
||||
PAYMENT_PROVIDERS=easypay,stripe
|
||||
```
|
||||
|
||||
**第二步**:通过 `ENABLED_PAYMENT_TYPES` 控制向用户展示哪些支付渠道:
|
||||
> **支付宝直连 / 微信支付直连**与**EasyPay**可以共存。直连渠道直接对接官方 API,资金直达商户账户,手续费更低;EasyPay 通过第三方聚合平台代收,接入门槛更低。
|
||||
|
||||
```env
|
||||
# 易支付支持: alipay, wxpay;Stripe 支持: stripe
|
||||
ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
```
|
||||
#### 支付宝直连
|
||||
|
||||
#### EasyPay(支付宝 / 微信支付)
|
||||
直接对接支付宝开放平台,支持 PC 页面支付(`alipay.trade.page.pay`)和手机网站支付(`alipay.trade.wap.pay`),自动根据终端类型切换。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| -------------------- | ----------------------- |
|
||||
| `ALIPAY_APP_ID` | 支付宝应用 AppID |
|
||||
| `ALIPAY_PRIVATE_KEY` | 应用私钥(内容或文件路径) |
|
||||
| `ALIPAY_PUBLIC_KEY` | 支付宝公钥(内容或文件路径) |
|
||||
| `ALIPAY_NOTIFY_URL` | 异步回调地址 |
|
||||
| `ALIPAY_RETURN_URL` | 同步跳转地址(可选) |
|
||||
|
||||
#### 微信支付直连
|
||||
|
||||
直接对接微信支付 APIv3,支持 Native 扫码支付和 H5 支付,移动端优先尝试 H5,自动 fallback 到扫码。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| ---------------------- | --------------------------- |
|
||||
| `WXPAY_APP_ID` | 微信支付 AppID |
|
||||
| `WXPAY_MCH_ID` | 商户号 |
|
||||
| `WXPAY_PRIVATE_KEY` | 商户 API 私钥(内容或文件路径) |
|
||||
| `WXPAY_CERT_SERIAL` | 商户证书序列号 |
|
||||
| `WXPAY_API_V3_KEY` | APIv3 密钥 |
|
||||
| `WXPAY_PUBLIC_KEY` | 微信支付公钥(内容或文件路径) |
|
||||
| `WXPAY_PUBLIC_KEY_ID` | 微信支付公钥 ID |
|
||||
| `WXPAY_NOTIFY_URL` | 异步回调地址 |
|
||||
|
||||
#### EasyPay(支付宝 / 微信支付聚合)
|
||||
|
||||
支付提供商只需兼容**易支付(EasyPay)协议**即可接入,例如 [ZPay](https://z-pay.cn/?uid=23808)(`https://z-pay.cn/?uid=23808`)等平台(链接含本项目作者的邀请码,介意可去掉)。
|
||||
|
||||
@@ -150,13 +174,17 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
|
||||
### 业务规则
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
| --------------------------- | ---------------------------------- | -------------------------- |
|
||||
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
|
||||
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
|
||||
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
|
||||
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
|
||||
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
|
||||
| 变量 | 说明 | 默认值 |
|
||||
| -------------------------------- | ---------------------------------------- | -------------------------- |
|
||||
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
|
||||
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
|
||||
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日每用户累计最高充值(元,`0` = 不限) | `10000` |
|
||||
| `MAX_DAILY_AMOUNT_ALIPAY` | 易支付支付宝渠道每日全局限额(可选) | 由提供商默认 |
|
||||
| `MAX_DAILY_AMOUNT_ALIPAY_DIRECT`| 支付宝直连渠道每日全局限额(可选) | 由提供商默认 |
|
||||
| `MAX_DAILY_AMOUNT_WXPAY` | 微信支付渠道每日全局限额(可选) | 由提供商默认 |
|
||||
| `MAX_DAILY_AMOUNT_STRIPE` | Stripe 渠道每日全局限额(可选) | 由提供商默认 |
|
||||
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
|
||||
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
|
||||
|
||||
### UI 定制(可选)
|
||||
|
||||
@@ -301,29 +329,91 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
|
||||
## 支付流程
|
||||
|
||||
```
|
||||
用户提交充值金额
|
||||
用户选择充值 / 订阅套餐
|
||||
│
|
||||
▼
|
||||
创建订单 (PENDING)
|
||||
├─ 校验用户状态 / 待支付订单数 / 每日限额
|
||||
├─ 校验用户状态 / 待支付订单数 / 每日限额 / 渠道限额
|
||||
└─ 调用支付提供商获取支付链接
|
||||
│
|
||||
▼
|
||||
用户完成支付
|
||||
├─ EasyPay → 扫码 / H5 跳转
|
||||
└─ Stripe → Payment Element (PaymentIntent)
|
||||
├─ 支付宝直连 → PC 页面支付 / H5 手机网站支付
|
||||
├─ 微信直连 → Native 扫码 / H5 支付
|
||||
├─ EasyPay → 扫码 / H5 跳转
|
||||
└─ Stripe → Payment Element (PaymentIntent)
|
||||
│
|
||||
▼
|
||||
支付回调(签名验证)→ 订单 PAID
|
||||
支付回调(RSA2 / MD5 / Webhook 签名验证)→ 订单 PAID
|
||||
│
|
||||
▼
|
||||
自动调用 Sub2API 充值接口
|
||||
├─ 成功 → COMPLETED,余额自动到账
|
||||
自动调用 Sub2API 充值 / 订阅接口
|
||||
├─ 成功 → COMPLETED,余额到账 / 订阅生效
|
||||
└─ 失败 → FAILED(管理员可重试)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 端点
|
||||
|
||||
所有 API 路径前缀为 `/api`。
|
||||
|
||||
### 公开 API
|
||||
|
||||
用户侧接口,通过 URL 参数 `user_id` + `token` 鉴权。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ---------------------------- | ---------------------------------------- |
|
||||
| `GET` | `/api/user` | 获取当前用户信息 |
|
||||
| `GET` | `/api/users/:id` | 获取指定用户信息 |
|
||||
| `POST` | `/api/orders` | 创建充值 / 订阅订单 |
|
||||
| `GET` | `/api/orders/:id` | 查询订单详情 |
|
||||
| `POST` | `/api/orders/:id/cancel` | 用户取消待支付订单 |
|
||||
| `GET` | `/api/orders/my` | 查询当前用户的订单列表 |
|
||||
| `GET` | `/api/channels` | 获取渠道列表(前端展示用) |
|
||||
| `GET` | `/api/subscription-plans` | 获取在售订阅套餐列表 |
|
||||
| `GET` | `/api/subscriptions/my` | 查询当前用户的订阅状态 |
|
||||
| `GET` | `/api/limits` | 查询充值限额与支付方式可用状态 |
|
||||
|
||||
### 支付回调
|
||||
|
||||
由支付服务商异步调用,签名验证后触发到账流程。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ---------------------------- | ---------------------------------------- |
|
||||
| `GET` | `/api/easy-pay/notify` | EasyPay 异步回调(GET 方式) |
|
||||
| `POST` | `/api/alipay/notify` | 支付宝直连异步回调 |
|
||||
| `POST` | `/api/wxpay/notify` | 微信支付直连异步回调 |
|
||||
| `POST` | `/api/stripe/webhook` | Stripe Webhook 回调 |
|
||||
|
||||
### 管理 API
|
||||
|
||||
需通过 `token` 参数传递 `ADMIN_TOKEN` 鉴权。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| -------- | ----------------------------------- | ---------------------------------- |
|
||||
| `GET` | `/api/admin/orders` | 订单列表(分页、状态筛选) |
|
||||
| `GET` | `/api/admin/orders/:id` | 订单详情(含审计日志) |
|
||||
| `POST` | `/api/admin/orders/:id/cancel` | 管理员取消订单 |
|
||||
| `POST` | `/api/admin/orders/:id/retry` | 重试失败的充值 / 订阅 |
|
||||
| `POST` | `/api/admin/refund` | 发起退款 |
|
||||
| `GET` | `/api/admin/dashboard` | 数据概览(收入统计、趋势) |
|
||||
| `GET` | `/api/admin/channels` | 渠道列表 |
|
||||
| `POST` | `/api/admin/channels` | 创建渠道 |
|
||||
| `PUT` | `/api/admin/channels/:id` | 更新渠道 |
|
||||
| `DELETE` | `/api/admin/channels/:id` | 删除渠道 |
|
||||
| `GET` | `/api/admin/subscription-plans` | 订阅套餐列表 |
|
||||
| `POST` | `/api/admin/subscription-plans` | 创建订阅套餐 |
|
||||
| `PUT` | `/api/admin/subscription-plans/:id` | 更新订阅套餐 |
|
||||
| `DELETE` | `/api/admin/subscription-plans/:id` | 删除订阅套餐 |
|
||||
| `GET` | `/api/admin/subscriptions` | 用户订阅记录列表 |
|
||||
| `GET` | `/api/admin/config` | 获取系统配置 |
|
||||
| `PUT` | `/api/admin/config` | 更新系统配置 |
|
||||
| `GET` | `/api/admin/sub2api/groups` | 从 Sub2API 同步渠道分组 |
|
||||
| `GET` | `/api/admin/sub2api/search-users` | 搜索 Sub2API 用户 |
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 环境要求
|
||||
|
||||
@@ -328,7 +328,7 @@ function ChannelsContent() {
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{t.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{t.missingTokenHint}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{t.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -721,13 +721,13 @@ 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 bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400">
|
||||
<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 bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400">
|
||||
<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>
|
||||
@@ -997,7 +997,7 @@ function ChannelsContent() {
|
||||
disabled={alreadyImported}
|
||||
checked={syncSelected.has(group.id)}
|
||||
onChange={() => toggleSyncGroup(group.id)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-indigo-500 focus:ring-indigo-500"
|
||||
className={`mt-0.5 h-4 w-4 rounded text-indigo-500 focus:ring-indigo-500 ${isDark ? 'border-slate-600 bg-slate-700' : 'border-slate-300'}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1061,7 +1061,7 @@ function ChannelsPageFallback() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ function DashboardContent() {
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -185,7 +185,7 @@ function DashboardPageFallback() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ function AdminContent() {
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -335,7 +335,7 @@ function AdminPageFallback() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ function DashboardContent() {
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -185,7 +185,7 @@ function DashboardPageFallback() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -596,7 +596,7 @@ function SubscriptionsContent() {
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{t.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{t.missingTokenHint}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{t.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -920,7 +920,7 @@ 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 ? 'text-green-600 dark:text-green-400' : 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>
|
||||
@@ -1241,7 +1241,7 @@ function SubscriptionsContent() {
|
||||
{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 ? 'text-green-600 dark:text-green-400' : 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>
|
||||
@@ -1431,7 +1431,7 @@ function SubscriptionsPageFallback() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,3 +16,31 @@ body {
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar - Dark theme */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #475569 #1e293b;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, sho
|
||||
<div
|
||||
className={[
|
||||
'inline-flex rounded-xl p-1',
|
||||
isDark ? 'bg-slate-800' : 'bg-slate-100',
|
||||
isDark ? 'bg-slate-900' : 'bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function SubscriptionConfirm({
|
||||
{isOpenAI && plan.defaultMappedModel && (
|
||||
<div className={[
|
||||
'flex items-center justify-between rounded-lg border px-3 py-2 text-sm',
|
||||
isDark ? 'border-green-500/20 bg-green-500/5' : 'border-green-500/20 bg-green-50/50',
|
||||
isDark ? 'border-green-500/30 bg-green-500/10' : 'border-green-200 bg-green-50/50',
|
||||
].join(' ')}>
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
{pickLocaleText(locale, '默认模型', 'Default Model')}
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
||||
{isOpenAI && plan.defaultMappedModel && (
|
||||
<div className={[
|
||||
'mb-4 flex items-center justify-between rounded-lg border px-3 py-2 text-sm',
|
||||
isDark ? 'border-green-500/20 bg-green-500/5' : 'border-green-500/20 bg-green-50/50',
|
||||
isDark ? 'border-green-500/30 bg-green-500/10' : 'border-green-200 bg-green-50/50',
|
||||
].join(' ')}>
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
{pickLocaleText(locale, '默认模型', 'Default Model')}
|
||||
|
||||
@@ -97,7 +97,12 @@ export default function UserSubscriptions({ subscriptions, onRenew, isDark, loca
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRenew(sub.group_id)}
|
||||
className="rounded-lg bg-emerald-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-emerald-600 active:bg-emerald-700"
|
||||
className={[
|
||||
'rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-colors',
|
||||
isDark
|
||||
? 'bg-emerald-500/80 hover:bg-emerald-500 active:bg-emerald-600'
|
||||
: 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '续费', 'Renew')}
|
||||
</button>
|
||||
@@ -107,13 +112,13 @@ export default function UserSubscriptions({ subscriptions, onRenew, isDark, loca
|
||||
{/* Dates */}
|
||||
<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">{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">{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>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
<th className={thCls}>{text.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200 bg-white'}`}>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60 bg-slate-900' : 'divide-gray-200 bg-white'}`}>
|
||||
{orders.map((order) => {
|
||||
const statusInfo = {
|
||||
label: formatStatus(order.status, locale),
|
||||
@@ -128,12 +128,12 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
{order.id.slice(0, 12)}...
|
||||
</button>
|
||||
</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-200' : ''}`}>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-200' : 'text-slate-900'}`}>
|
||||
{order.userName || `#${order.userId}`}
|
||||
</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' : ''}`}>
|
||||
<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>
|
||||
|
||||
@@ -81,12 +81,12 @@ export default function RefundDialog({
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.orderId}</div>
|
||||
<div className="text-sm font-mono">{orderId}</div>
|
||||
<div className={['text-sm font-mono', dark ? 'text-slate-200' : 'text-gray-900'].join(' ')}>{orderId}</div>
|
||||
</div>
|
||||
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.amount}</div>
|
||||
<div className="text-lg font-bold text-red-600">
|
||||
<div className={['text-lg font-bold', dark ? 'text-red-400' : 'text-red-600'].join(' ')}>
|
||||
{currency}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@ export default function RefundDialog({
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
className={['rounded', dark ? 'border-slate-600' : 'border-gray-300'].join(' ')}
|
||||
/>
|
||||
<span className="text-red-600">{text.forceRefund}</span>
|
||||
<span className={dark ? 'text-red-400' : 'text-red-600'}>{text.forceRefund}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
@@ -147,7 +147,10 @@ export default function RefundDialog({
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading || (requireForce && !force)}
|
||||
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
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',
|
||||
].join(' ')}
|
||||
>
|
||||
{loading ? text.processing : text.confirm}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user