style: 全量 prettier 格式化
This commit is contained in:
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
@@ -2,6 +2,5 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/third-party/sub2api" vcs="Git" />
|
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
119
README.en.md
119
README.en.md
@@ -34,15 +34,15 @@ Sub2ApiPay is a self-hosted recharge payment gateway built for the [Sub2API](htt
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Category | Technology |
|
| Category | Technology |
|
||||||
|----------|------------|
|
| --------------- | -------------------------- |
|
||||||
| Framework | Next.js 16 (App Router) |
|
| Framework | Next.js 16 (App Router) |
|
||||||
| Language | TypeScript 5 + React 19 |
|
| Language | TypeScript 5 + React 19 |
|
||||||
| Styling | TailwindCSS 4 |
|
| Styling | TailwindCSS 4 |
|
||||||
| ORM | Prisma 7 (adapter-pg mode) |
|
| ORM | Prisma 7 (adapter-pg mode) |
|
||||||
| Database | PostgreSQL 16 |
|
| Database | PostgreSQL 16 |
|
||||||
| Container | Docker + Docker Compose |
|
| Container | Docker + Docker Compose |
|
||||||
| Package Manager | pnpm |
|
| Package Manager | pnpm |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,12 +85,12 @@ See [`.env.example`](./.env.example) for the full template.
|
|||||||
|
|
||||||
### Core (Required)
|
### Core (Required)
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
| ----------------------- | ---------------------------------------------------------- |
|
||||||
| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` |
|
| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` |
|
||||||
| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key |
|
| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key |
|
||||||
| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) |
|
| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) |
|
||||||
| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` |
|
| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` |
|
||||||
|
|
||||||
> `DATABASE_URL` is automatically injected by Docker Compose when using the bundled database.
|
> `DATABASE_URL` is automatically injected by Docker Compose when using the bundled database.
|
||||||
|
|
||||||
@@ -127,49 +127,50 @@ Any payment provider compatible with the **EasyPay protocol** can be used, such
|
|||||||
|
|
||||||
> **Disclaimer**: Please evaluate the security, reliability, and compliance of any third-party payment provider on your own. This project does not endorse or guarantee any specific provider.
|
> **Disclaimer**: Please evaluate the security, reliability, and compliance of any third-party payment provider on your own. This project does not endorse or guarantee any specific provider.
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
| --------------------- | ---------------------------------------------------------------- |
|
||||||
| `EASY_PAY_PID` | EasyPay merchant ID |
|
| `EASY_PAY_PID` | EasyPay merchant ID |
|
||||||
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
|
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
|
||||||
| `EASY_PAY_API_BASE` | EasyPay API base URL |
|
| `EASY_PAY_API_BASE` | EasyPay API base URL |
|
||||||
| `EASY_PAY_NOTIFY_URL` | Async callback URL: `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
|
| `EASY_PAY_NOTIFY_URL` | Async callback URL: `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
|
||||||
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` |
|
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` |
|
||||||
| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) |
|
| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) |
|
||||||
| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) |
|
| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) |
|
||||||
|
|
||||||
#### Stripe
|
#### Stripe
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
| ------------------------ | ------------------------------------------- |
|
||||||
| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) |
|
| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) |
|
||||||
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) |
|
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) |
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
|
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
|
||||||
|
|
||||||
> Stripe webhook endpoint: `${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
|
> Stripe webhook endpoint: `${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
|
||||||
> Subscribe to: `payment_intent.succeeded`, `payment_intent.payment_failed`
|
> Subscribe to: `payment_intent.succeeded`, `payment_intent.payment_failed`
|
||||||
|
|
||||||
### Business Rules
|
### Business Rules
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
| --------------------------- | ----------------------------------------------- | -------------------------- |
|
||||||
| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` |
|
| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` |
|
||||||
| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` |
|
| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` |
|
||||||
| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` |
|
| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` |
|
||||||
| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` |
|
| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` |
|
||||||
| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` |
|
| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` |
|
||||||
|
|
||||||
### UI Customization (Optional)
|
### UI Customization (Optional)
|
||||||
|
|
||||||
Display a support contact image and description on the right side of the payment page.
|
Display a support contact image and description on the right side of the payment page.
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
| -------------------- | ------------------------------------------------------------------------------- |
|
||||||
| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) |
|
| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) |
|
||||||
| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMon–Fri 9am–6pm` |
|
| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMon–Fri 9am–6pm` |
|
||||||
|
|
||||||
**Two ways to provide the image:**
|
**Two ways to provide the image:**
|
||||||
|
|
||||||
- **External URL** (recommended — no Compose changes needed): any publicly accessible image link (CDN, OSS, image hosting).
|
- **External URL** (recommended — no Compose changes needed): any publicly accessible image link (CDN, OSS, image hosting).
|
||||||
|
|
||||||
```env
|
```env
|
||||||
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
|
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
|
||||||
```
|
```
|
||||||
@@ -188,9 +189,9 @@ Display a support contact image and description on the right side of the payment
|
|||||||
|
|
||||||
### Docker Compose Variables
|
### Docker Compose Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
| ------------- | -------------------------------- | ------------------------------------- |
|
||||||
| `APP_PORT` | Host port mapping | `3001` |
|
| `APP_PORT` | Host port mapping | `3001` |
|
||||||
| `DB_PASSWORD` | PostgreSQL password (bundled DB) | `password` (**change in production**) |
|
| `DB_PASSWORD` | PostgreSQL password (bundled DB) | `password` (**change in production**) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -266,19 +267,19 @@ docker compose exec app npx prisma migrate deploy
|
|||||||
|
|
||||||
The following page URLs can be configured in the Sub2API admin panel:
|
The following page URLs can be configured in the Sub2API admin panel:
|
||||||
|
|
||||||
| Page | URL | Description |
|
| Page | URL | Description |
|
||||||
|------|-----|-------------|
|
| ---------------- | ------------------------------------ | ------------------------------------- |
|
||||||
| Payment | `https://pay.example.com/pay` | User recharge entry |
|
| Payment | `https://pay.example.com/pay` | User recharge entry |
|
||||||
| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge history |
|
| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge history |
|
||||||
| Order Management | `https://pay.example.com/admin` | Sub2API admin only |
|
| Order Management | `https://pay.example.com/admin` | Sub2API admin only |
|
||||||
|
|
||||||
Sub2API **v0.1.88** and above will automatically append the following parameters — no manual query string needed:
|
Sub2API **v0.1.88** and above will automatically append the following parameters — no manual query string needed:
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
|-----------|-------------|
|
| --------- | ------------------------------------------------- |
|
||||||
| `user_id` | Sub2API user ID |
|
| `user_id` | Sub2API user ID |
|
||||||
| `token` | User login token (required to view order history) |
|
| `token` | User login token (required to view order history) |
|
||||||
| `theme` | `light` (default) or `dark` |
|
| `theme` | `light` (default) or `dark` |
|
||||||
| `ui_mode` | `standalone` (default) or `embedded` (for iframe) |
|
| `ui_mode` | `standalone` (default) or `embedded` (for iframe) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -287,13 +288,13 @@ Sub2API **v0.1.88** and above will automatically append the following parameters
|
|||||||
|
|
||||||
Access: `https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
|
Access: `https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
| -------------- | ----------------------------------------------------- |
|
||||||
| Order List | Filter by status, paginate, choose 20/50/100 per page |
|
| Order List | Filter by status, paginate, choose 20/50/100 per page |
|
||||||
| Order Detail | View all fields and audit log timeline |
|
| Order Detail | View all fields and audit log timeline |
|
||||||
| Retry Recharge | Re-trigger recharge for paid-but-failed orders |
|
| Retry Recharge | Re-trigger recharge for paid-but-failed orders |
|
||||||
| Cancel Order | Force-cancel pending orders |
|
| Cancel Order | Force-cancel pending orders |
|
||||||
| Refund | Issue refund and deduct Sub2API balance |
|
| Refund | Issue refund and deduct Sub2API balance |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
117
README.md
117
README.md
@@ -34,15 +34,15 @@ Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管充值
|
|||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 类别 | 技术 |
|
| 类别 | 技术 |
|
||||||
|------|------|
|
| ------ | --------------------------- |
|
||||||
| 框架 | Next.js 16 (App Router) |
|
| 框架 | Next.js 16 (App Router) |
|
||||||
| 语言 | TypeScript 5 + React 19 |
|
| 语言 | TypeScript 5 + React 19 |
|
||||||
| 样式 | TailwindCSS 4 |
|
| 样式 | TailwindCSS 4 |
|
||||||
| ORM | Prisma 7(adapter-pg 模式) |
|
| ORM | Prisma 7(adapter-pg 模式) |
|
||||||
| 数据库 | PostgreSQL 16 |
|
| 数据库 | PostgreSQL 16 |
|
||||||
| 容器 | Docker + Docker Compose |
|
| 容器 | Docker + Docker Compose |
|
||||||
| 包管理 | pnpm |
|
| 包管理 | pnpm |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,12 +85,12 @@ docker compose up -d --build
|
|||||||
|
|
||||||
### 核心(必填)
|
### 核心(必填)
|
||||||
|
|
||||||
| 变量 | 说明 |
|
| 变量 | 说明 |
|
||||||
|------|------|
|
| ----------------------- | ---------------------------------------------- |
|
||||||
| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` |
|
| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` |
|
||||||
| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 |
|
| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 |
|
||||||
| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) |
|
| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) |
|
||||||
| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` |
|
| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` |
|
||||||
|
|
||||||
> `DATABASE_URL` 使用自带数据库时由 Compose 自动注入,无需手动填写。
|
> `DATABASE_URL` 使用自带数据库时由 Compose 自动注入,无需手动填写。
|
||||||
|
|
||||||
@@ -127,49 +127,50 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
|
|||||||
|
|
||||||
> **注意**:支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
|
> **注意**:支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
|
||||||
|
|
||||||
| 变量 | 说明 |
|
| 变量 | 说明 |
|
||||||
|------|------|
|
| --------------------- | ------------------------------------------------------------- |
|
||||||
| `EASY_PAY_PID` | EasyPay 商户 ID |
|
| `EASY_PAY_PID` | EasyPay 商户 ID |
|
||||||
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
|
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
|
||||||
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
|
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
|
||||||
| `EASY_PAY_NOTIFY_URL` | 异步回调地址,填 `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
|
| `EASY_PAY_NOTIFY_URL` | 异步回调地址,填 `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
|
||||||
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` |
|
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` |
|
||||||
| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID(可选) |
|
| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID(可选) |
|
||||||
| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID(可选) |
|
| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID(可选) |
|
||||||
|
|
||||||
#### Stripe
|
#### Stripe
|
||||||
|
|
||||||
| 变量 | 说明 |
|
| 变量 | 说明 |
|
||||||
|------|------|
|
| ------------------------ | -------------------------------------- |
|
||||||
| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...`) |
|
| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...`) |
|
||||||
| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...`) |
|
| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...`) |
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...`) |
|
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...`) |
|
||||||
|
|
||||||
> Stripe Webhook 端点:`${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
|
> Stripe Webhook 端点:`${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
|
||||||
> 需订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed`
|
> 需订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed`
|
||||||
|
|
||||||
### 业务规则
|
### 业务规则
|
||||||
|
|
||||||
| 变量 | 说明 | 默认值 |
|
| 变量 | 说明 | 默认值 |
|
||||||
|------|------|--------|
|
| --------------------------- | ---------------------------------- | -------------------------- |
|
||||||
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
|
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
|
||||||
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
|
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
|
||||||
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
|
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
|
||||||
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
|
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
|
||||||
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
|
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
|
||||||
|
|
||||||
### UI 定制(可选)
|
### UI 定制(可选)
|
||||||
|
|
||||||
在充值页面右侧可展示客服联系方式、说明图片等帮助内容。
|
在充值页面右侧可展示客服联系方式、说明图片等帮助内容。
|
||||||
|
|
||||||
| 变量 | 说明 |
|
| 变量 | 说明 |
|
||||||
|------|------|
|
| -------------------- | --------------------------------------------------------------- |
|
||||||
| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) |
|
| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) |
|
||||||
| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` |
|
| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` |
|
||||||
|
|
||||||
**图片地址两种方式:**
|
**图片地址两种方式:**
|
||||||
|
|
||||||
- **外部 URL**(推荐,无需改 Compose 配置):直接填图片的公网地址,如 OSS / CDN / 图床链接。
|
- **外部 URL**(推荐,无需改 Compose 配置):直接填图片的公网地址,如 OSS / CDN / 图床链接。
|
||||||
|
|
||||||
```env
|
```env
|
||||||
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
|
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
|
||||||
```
|
```
|
||||||
@@ -188,9 +189,9 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
|
|||||||
|
|
||||||
### Docker Compose 专用
|
### Docker Compose 专用
|
||||||
|
|
||||||
| 变量 | 说明 | 默认值 |
|
| 变量 | 说明 | 默认值 |
|
||||||
|------|------|--------|
|
| ------------- | ----------------------------------- | ---------------------------- |
|
||||||
| `APP_PORT` | 宿主机映射端口 | `3001` |
|
| `APP_PORT` | 宿主机映射端口 | `3001` |
|
||||||
| `DB_PASSWORD` | PostgreSQL 密码(使用自带数据库时) | `password`(**生产请修改**) |
|
| `DB_PASSWORD` | PostgreSQL 密码(使用自带数据库时) | `password`(**生产请修改**) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -266,19 +267,19 @@ docker compose exec app npx prisma migrate deploy
|
|||||||
|
|
||||||
在 Sub2API 管理后台可配置以下页面链接:
|
在 Sub2API 管理后台可配置以下页面链接:
|
||||||
|
|
||||||
| 页面 | 链接 | 说明 |
|
| 页面 | 链接 | 说明 |
|
||||||
|------|------|------|
|
| -------- | ------------------------------------ | ----------------------- |
|
||||||
| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 |
|
| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 |
|
||||||
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 |
|
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 |
|
||||||
| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 |
|
| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 |
|
||||||
|
|
||||||
Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添加:
|
Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添加:
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
|------|------|
|
| --------- | ------------------------------------------------ |
|
||||||
| `user_id` | Sub2API 用户 ID |
|
| `user_id` | Sub2API 用户 ID |
|
||||||
| `token` | 用户登录 Token(有 token 才能查看订单历史) |
|
| `token` | 用户登录 Token(有 token 才能查看订单历史) |
|
||||||
| `theme` | `light`(默认)或 `dark` |
|
| `theme` | `light`(默认)或 `dark` |
|
||||||
| `ui_mode` | `standalone`(默认)或 `embedded`(iframe 嵌入) |
|
| `ui_mode` | `standalone`(默认)或 `embedded`(iframe 嵌入) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -287,13 +288,13 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
|
|||||||
|
|
||||||
访问:`https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
|
访问:`https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
| -------- | ------------------------------------------- |
|
||||||
| 订单列表 | 按状态筛选、分页浏览,支持每页 20/50/100 条 |
|
| 订单列表 | 按状态筛选、分页浏览,支持每页 20/50/100 条 |
|
||||||
| 订单详情 | 查看完整字段与操作审计日志 |
|
| 订单详情 | 查看完整字段与操作审计日志 |
|
||||||
| 重试充值 | 对已支付但充值失败的订单重新发起充值 |
|
| 重试充值 | 对已支付但充值失败的订单重新发起充值 |
|
||||||
| 取消订单 | 强制取消待支付订单 |
|
| 取消订单 | 强制取消待支付订单 |
|
||||||
| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 |
|
| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ interface DashboardData {
|
|||||||
avgAmount: number;
|
avgAmount: number;
|
||||||
};
|
};
|
||||||
dailySeries: { date: string; amount: number; count: number }[];
|
dailySeries: { date: string; amount: number; count: number }[];
|
||||||
leaderboard: { userId: number; userName: string | null; userEmail: string | null; totalAmount: number; orderCount: number }[];
|
leaderboard: {
|
||||||
|
userId: number;
|
||||||
|
userName: string | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
totalAmount: number;
|
||||||
|
orderCount: number;
|
||||||
|
}[];
|
||||||
paymentMethods: { paymentType: string; amount: number; count: number; percentage: number }[];
|
paymentMethods: { paymentType: string; amount: number; count: number; percentage: number }[];
|
||||||
meta: { days: number; generatedAt: string };
|
meta: { days: number; generatedAt: string };
|
||||||
}
|
}
|
||||||
@@ -79,7 +85,9 @@ function DashboardContent() {
|
|||||||
|
|
||||||
const btnBase = [
|
const btnBase = [
|
||||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
'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(' ');
|
].join(' ');
|
||||||
|
|
||||||
const btnActive = [
|
const btnActive = [
|
||||||
@@ -97,12 +105,7 @@ function DashboardContent() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{DAYS_OPTIONS.map((d) => (
|
{DAYS_OPTIONS.map((d) => (
|
||||||
<button
|
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
|
||||||
key={d}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDays(d)}
|
|
||||||
className={days === d ? btnActive : btnBase}
|
|
||||||
>
|
|
||||||
{d}天
|
{d}天
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -116,7 +119,9 @@ function DashboardContent() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}>
|
<div
|
||||||
|
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
|
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
|
||||||
✕
|
✕
|
||||||
|
|||||||
@@ -169,7 +169,9 @@ function AdminContent() {
|
|||||||
|
|
||||||
const btnBase = [
|
const btnBase = [
|
||||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
'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(' ');
|
].join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -191,7 +193,9 @@ function AdminContent() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}>
|
<div
|
||||||
|
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
|
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
|
||||||
✕
|
✕
|
||||||
@@ -211,8 +215,12 @@ function AdminContent() {
|
|||||||
className={[
|
className={[
|
||||||
'rounded-full px-3 py-1 text-sm transition-colors',
|
'rounded-full px-3 py-1 text-sm transition-colors',
|
||||||
statusFilter === s
|
statusFilter === s
|
||||||
? (isDark ? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40' : 'bg-blue-600 text-white')
|
? isDark
|
||||||
: (isDark ? 'bg-slate-800 text-slate-400 hover:bg-slate-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'),
|
? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40'
|
||||||
|
: 'bg-blue-600 text-white'
|
||||||
|
: isDark
|
||||||
|
? 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{statusLabels[s]}
|
{statusLabels[s]}
|
||||||
@@ -221,11 +229,22 @@ function AdminContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className={['rounded-xl border', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-xl border',
|
||||||
|
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div>
|
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div>
|
||||||
) : (
|
) : (
|
||||||
<OrderTable orders={orders} onRetry={handleRetry} onCancel={handleCancel} onViewDetail={handleViewDetail} dark={isDark} />
|
<OrderTable
|
||||||
|
orders={orders}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onViewDetail={handleViewDetail}
|
||||||
|
dark={isDark}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -236,7 +255,10 @@ function AdminContent() {
|
|||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onPageChange={(p) => setPage(p)}
|
onPageChange={(p) => setPage(p)}
|
||||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
|
onPageSizeChange={(s) => {
|
||||||
|
setPageSize(s);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,13 @@ export async function GET(request: NextRequest) {
|
|||||||
`,
|
`,
|
||||||
// Leaderboard: GROUP BY user_id only, MAX() for name/email
|
// Leaderboard: GROUP BY user_id only, MAX() for name/email
|
||||||
prisma.$queryRaw<
|
prisma.$queryRaw<
|
||||||
{ user_id: number; user_name: string | null; user_email: string | null; total_amount: string; order_count: bigint }[]
|
{
|
||||||
|
user_id: number;
|
||||||
|
user_name: string | null;
|
||||||
|
user_email: string | null;
|
||||||
|
total_amount: string;
|
||||||
|
order_count: bigint;
|
||||||
|
}[]
|
||||||
>`
|
>`
|
||||||
SELECT user_id, MAX(user_name) as user_name, MAX(user_email) as user_email,
|
SELECT user_id, MAX(user_name) as user_name, MAX(user_email) as user_email,
|
||||||
SUM(amount)::text as total_amount, COUNT(*) as order_count
|
SUM(amount)::text as total_amount, COUNT(*) as order_count
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
|||||||
import { adminCancelOrder, OrderError } from '@/lib/order/service';
|
import { adminCancelOrder, OrderError } from '@/lib/order/service';
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
|||||||
import { retryRecharge, OrderError } from '@/lib/order/service';
|
import { retryRecharge, OrderError } from '@/lib/order/service';
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
|||||||
import { Prisma, OrderStatus } from '@prisma/client';
|
import { Prisma, OrderStatus } from '@prisma/client';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const page = Math.max(1, Number(searchParams.get('page') || '1'));
|
const page = Math.max(1, Number(searchParams.get('page') || '1'));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const refundSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||||||
return NextResponse.json({ received: true });
|
return NextResponse.json({ received: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Stripe webhook error:', error);
|
console.error('Stripe webhook error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 400 });
|
||||||
{ error: 'Webhook processing failed' },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const [user, methodLimits] = await Promise.all([
|
const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(env.ENABLED_PAYMENT_TYPES)]);
|
||||||
getUser(userId),
|
|
||||||
queryMethodLimits(env.ENABLED_PAYMENT_TYPES),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
@@ -29,9 +26,10 @@ export async function GET(request: NextRequest) {
|
|||||||
methodLimits,
|
methodLimits,
|
||||||
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
|
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
|
||||||
helpText: env.PAY_HELP_TEXT ?? null,
|
helpText: env.PAY_HELP_TEXT ?? null,
|
||||||
stripePublishableKey: env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
|
stripePublishableKey:
|
||||||
? env.STRIPE_PUBLISHABLE_KEY
|
env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
|
||||||
: null,
|
? env.STRIPE_PUBLISHABLE_KEY
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -134,17 +134,20 @@ function OrdersContent() {
|
|||||||
loadOrders(1, newSize);
|
loadOrders(1, newSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredOrders =
|
const filteredOrders = activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
|
||||||
activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
|
|
||||||
|
|
||||||
const btnClass = [
|
const btnClass = [
|
||||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
'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(' ');
|
].join(' ');
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}>
|
<div
|
||||||
|
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
|
||||||
|
>
|
||||||
正在切换到移动端订单 Tab...
|
正在切换到移动端订单 Tab...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -178,8 +181,14 @@ function OrdersContent() {
|
|||||||
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
|
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>刷新</button>
|
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
|
||||||
{!srcHost && <a href={buildScopedUrl('/pay')} className={btnClass}>返回充值</a>}
|
刷新
|
||||||
|
</button>
|
||||||
|
{!srcHost && (
|
||||||
|
<a href={buildScopedUrl('/pay')} className={btnClass}>
|
||||||
|
返回充值
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -208,7 +217,13 @@ function OrdersContent() {
|
|||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center"><div className="text-gray-500">加载中...</div></div>}>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-gray-500">加载中...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<OrdersContent />
|
<OrdersContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,16 +72,18 @@ function StripePopupContent() {
|
|||||||
|
|
||||||
if (isAlipay) {
|
if (isAlipay) {
|
||||||
// Alipay: confirm directly and redirect, no Payment Element needed
|
// Alipay: confirm directly and redirect, no Payment Element needed
|
||||||
stripe.confirmAlipayPayment(clientSecret, {
|
stripe
|
||||||
return_url: buildReturnUrl(),
|
.confirmAlipayPayment(clientSecret, {
|
||||||
}).then((result) => {
|
return_url: buildReturnUrl(),
|
||||||
if (cancelled) return;
|
})
|
||||||
if (result.error) {
|
.then((result) => {
|
||||||
setStripeError(result.error.message || '支付失败,请重试');
|
if (cancelled) return;
|
||||||
setStripeLoaded(true);
|
if (result.error) {
|
||||||
}
|
setStripeError(result.error.message || '支付失败,请重试');
|
||||||
// If no error, the page has already been redirected
|
setStripeLoaded(true);
|
||||||
});
|
}
|
||||||
|
// If no error, the page has already been redirected
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +99,9 @@ function StripePopupContent() {
|
|||||||
setStripeLoaded(true);
|
setStripeLoaded(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [credentials, isDark, isAlipay, buildReturnUrl]);
|
}, [credentials, isDark, isAlipay, buildReturnUrl]);
|
||||||
|
|
||||||
// Mount Payment Element (only for non-alipay methods)
|
// Mount Payment Element (only for non-alipay methods)
|
||||||
@@ -151,12 +155,12 @@ function StripePopupContent() {
|
|||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||||
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
|
<div
|
||||||
|
className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>正在初始化...</span>
|
||||||
正在初始化...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,18 +171,19 @@ function StripePopupContent() {
|
|||||||
if (isAlipay) {
|
if (isAlipay) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||||
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
|
<div
|
||||||
|
className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}
|
||||||
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold text-blue-600">{'\u00A5'}{amount.toFixed(2)}</div>
|
<div className="text-3xl font-bold text-blue-600">
|
||||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
{'\u00A5'}
|
||||||
订单号: {orderId}
|
{amount.toFixed(2)}
|
||||||
</p>
|
</div>
|
||||||
|
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>订单号: {orderId}</p>
|
||||||
</div>
|
</div>
|
||||||
{stripeError ? (
|
{stripeError ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
|
||||||
{stripeError}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.close()}
|
onClick={() => window.close()}
|
||||||
@@ -202,20 +207,21 @@ function StripePopupContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||||
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
|
<div
|
||||||
|
className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}
|
||||||
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold text-blue-600">{'\u00A5'}{amount.toFixed(2)}</div>
|
<div className="text-3xl font-bold text-blue-600">
|
||||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
{'\u00A5'}
|
||||||
订单号: {orderId}
|
{amount.toFixed(2)}
|
||||||
</p>
|
</div>
|
||||||
|
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>订单号: {orderId}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!stripeLoaded ? (
|
{!stripeLoaded ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>正在加载支付表单...</span>
|
||||||
正在加载支付表单...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : stripeSuccess ? (
|
) : stripeSuccess ? (
|
||||||
<div className="py-6 text-center">
|
<div className="py-6 text-center">
|
||||||
@@ -234,9 +240,7 @@ function StripePopupContent() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{stripeError && (
|
{stripeError && (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
|
||||||
{stripeError}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={stripeContainerRef}
|
ref={stripeContainerRef}
|
||||||
|
|||||||
@@ -125,9 +125,7 @@ export default function MobileOrderList({
|
|||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div ref={sentinelRef} className="py-3 text-center">
|
<div ref={sentinelRef} className="py-3 text-center">
|
||||||
{loadingMore ? (
|
{loadingMore ? (
|
||||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>加载中...</span>
|
||||||
加载中...
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>
|
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>
|
||||||
上滑加载更多
|
上滑加载更多
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ export default function PaginationBar({
|
|||||||
{/* 左侧:统计 + 每页大小 */}
|
{/* 左侧:统计 + 每页大小 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||||
共 {total} 条
|
共 {total} 条{totalPages > 1 && `,第 ${page} / ${totalPages} 页`}
|
||||||
{totalPages > 1 && `,第 ${page} / ${totalPages} 页`}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{onPageSizeChange && (
|
{onPageSizeChange && (
|
||||||
@@ -47,7 +46,9 @@ export default function PaginationBar({
|
|||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => { onPageSizeChange(s); }}
|
onClick={() => {
|
||||||
|
onPageSizeChange(s);
|
||||||
|
}}
|
||||||
className={[
|
className={[
|
||||||
'rounded border px-2 py-1 font-medium transition-colors',
|
'rounded border px-2 py-1 font-medium transition-colors',
|
||||||
pageSize === s
|
pageSize === s
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const TEXT_GO_PAY = '\u70B9\u51FB\u524D\u5F80\u652F\u4ED8';
|
|||||||
const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8';
|
const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8';
|
||||||
const TEXT_BACK = '\u8FD4\u56DE';
|
const TEXT_BACK = '\u8FD4\u56DE';
|
||||||
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
|
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
|
||||||
const TEXT_H5_HINT = '\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4';
|
const TEXT_H5_HINT =
|
||||||
|
'\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4';
|
||||||
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
|
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
|
||||||
|
|
||||||
export default function PaymentQRCode({
|
export default function PaymentQRCode({
|
||||||
@@ -215,11 +216,7 @@ export default function PaymentQRCode({
|
|||||||
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
|
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
|
||||||
popupUrl.searchParams.set('method', stripePaymentMethod);
|
popupUrl.searchParams.set('method', stripePaymentMethod);
|
||||||
|
|
||||||
const popup = window.open(
|
const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes');
|
||||||
popupUrl.toString(),
|
|
||||||
'stripe_payment',
|
|
||||||
'width=500,height=700,scrollbars=yes',
|
|
||||||
);
|
|
||||||
if (!popup || popup.closed) {
|
if (!popup || popup.closed) {
|
||||||
setPopupBlocked(true);
|
setPopupBlocked(true);
|
||||||
return;
|
return;
|
||||||
@@ -228,11 +225,14 @@ export default function PaymentQRCode({
|
|||||||
const onReady = (event: MessageEvent) => {
|
const onReady = (event: MessageEvent) => {
|
||||||
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
|
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
|
||||||
window.removeEventListener('message', onReady);
|
window.removeEventListener('message', onReady);
|
||||||
popup.postMessage({
|
popup.postMessage(
|
||||||
type: 'STRIPE_POPUP_INIT',
|
{
|
||||||
clientSecret,
|
type: 'STRIPE_POPUP_INIT',
|
||||||
publishableKey: stripePublishableKey,
|
clientSecret,
|
||||||
}, window.location.origin);
|
publishableKey: stripePublishableKey,
|
||||||
|
},
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
window.addEventListener('message', onReady);
|
window.addEventListener('message', onReady);
|
||||||
};
|
};
|
||||||
@@ -324,7 +324,9 @@ export default function PaymentQRCode({
|
|||||||
<div className="text-6xl text-green-600">{'\u2713'}</div>
|
<div className="text-6xl text-green-600">{'\u2713'}</div>
|
||||||
<h2 className="text-xl font-bold text-green-600">{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}</h2>
|
<h2 className="text-xl font-bold text-green-600">{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}</h2>
|
||||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||||
{'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'}
|
{
|
||||||
|
'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
@@ -339,7 +341,10 @@ export default function PaymentQRCode({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-4xl font-bold text-blue-600">{'\u00A5'}{displayAmount.toFixed(2)}</div>
|
<div className="text-4xl font-bold text-blue-600">
|
||||||
|
{'\u00A5'}
|
||||||
|
{displayAmount.toFixed(2)}
|
||||||
|
</div>
|
||||||
{hasFeeDiff && (
|
{hasFeeDiff && (
|
||||||
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||||
到账 ¥{amount.toFixed(2)}
|
到账 ¥{amount.toFixed(2)}
|
||||||
@@ -355,7 +360,12 @@ export default function PaymentQRCode({
|
|||||||
{isStripe ? (
|
{isStripe ? (
|
||||||
<div className="w-full max-w-md space-y-4">
|
<div className="w-full max-w-md space-y-4">
|
||||||
{!clientSecret || !stripePublishableKey ? (
|
{!clientSecret || !stripePublishableKey ? (
|
||||||
<div className={['rounded-lg border-2 border-dashed p-8 text-center', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-lg border-2 border-dashed p-8 text-center',
|
||||||
|
dark ? 'border-slate-700' : 'border-gray-300',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||||
支付初始化失败,请返回重试
|
支付初始化失败,请返回重试
|
||||||
</p>
|
</p>
|
||||||
@@ -368,14 +378,15 @@ export default function PaymentQRCode({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : stripeError && !stripeLib ? (
|
) : stripeError && !stripeLib ? (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
|
||||||
{stripeError}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={stripeContainerRef}
|
ref={stripeContainerRef}
|
||||||
className={['rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}
|
className={[
|
||||||
|
'rounded-lg border p-4',
|
||||||
|
dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white',
|
||||||
|
].join(' ')}
|
||||||
/>
|
/>
|
||||||
{stripeError && (
|
{stripeError && (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||||
@@ -412,7 +423,14 @@ export default function PaymentQRCode({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{popupBlocked && (
|
{popupBlocked && (
|
||||||
<div className={['rounded-lg border p-3 text-sm', dark ? 'border-amber-700 bg-amber-900/30 text-amber-300' : 'border-amber-200 bg-amber-50 text-amber-700'].join(' ')}>
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-lg border p-3 text-sm',
|
||||||
|
dark
|
||||||
|
? 'border-amber-700 bg-amber-900/30 text-amber-300'
|
||||||
|
: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
弹出窗口被浏览器拦截,请允许本站弹出窗口后重试
|
弹出窗口被浏览器拦截,请允许本站弹出窗口后重试
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -437,7 +455,12 @@ export default function PaymentQRCode({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{qrDataUrl && (
|
{qrDataUrl && (
|
||||||
<div className={['relative rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}>
|
<div
|
||||||
|
className={[
|
||||||
|
'relative rounded-lg border p-4',
|
||||||
|
dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
{imageLoading && (
|
{imageLoading && (
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
@@ -465,7 +488,12 @@ export default function PaymentQRCode({
|
|||||||
|
|
||||||
{!qrDataUrl && !payUrl && (
|
{!qrDataUrl && !payUrl && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className={['rounded-lg border-2 border-dashed p-8', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-lg border-2 border-dashed p-8',
|
||||||
|
dark ? 'border-slate-700' : 'border-gray-300',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
|
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -484,7 +512,9 @@ export default function PaymentQRCode({
|
|||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className={[
|
className={[
|
||||||
'flex-1 rounded-lg border py-2 text-sm',
|
'flex-1 rounded-lg border py-2 text-sm',
|
||||||
dark ? 'border-slate-700 text-slate-300 hover:bg-slate-800' : 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
dark
|
||||||
|
? 'border-slate-700 text-slate-300 hover:bg-slate-800'
|
||||||
|
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{TEXT_BACK}
|
{TEXT_BACK}
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ function CustomTooltip({
|
|||||||
<p className={['mb-1 text-xs', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{label}</p>
|
<p className={['mb-1 text-xs', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{label}</p>
|
||||||
{payload.map((p) => (
|
{payload.map((p) => (
|
||||||
<p key={p.dataKey}>
|
<p key={p.dataKey}>
|
||||||
{p.dataKey === 'amount' ? '金额' : '笔数'}: {p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value}
|
{p.dataKey === 'amount' ? '金额' : '笔数'}:{' '}
|
||||||
|
{p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -63,8 +64,15 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
|||||||
const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0;
|
const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0;
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
|
<div
|
||||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>每日充值趋势</h3>
|
className={[
|
||||||
|
'rounded-xl border p-6',
|
||||||
|
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||||
|
每日充值趋势
|
||||||
|
</h3>
|
||||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -74,8 +82,15 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
|||||||
const gridColor = dark ? '#334155' : '#e2e8f0';
|
const gridColor = dark ? '#334155' : '#e2e8f0';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
|
<div
|
||||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>每日充值趋势</h3>
|
className={[
|
||||||
|
'rounded-xl border p-6',
|
||||||
|
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||||
|
每日充值趋势
|
||||||
|
</h3>
|
||||||
<ResponsiveContainer width="100%" height={320}>
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
<LineChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
|
<LineChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
|
||||||
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" />
|
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" />
|
||||||
|
|||||||
@@ -29,24 +29,14 @@ export default function DashboardStats({ summary, dark }: DashboardStatsProps) {
|
|||||||
key={card.label}
|
key={card.label}
|
||||||
className={[
|
className={[
|
||||||
'rounded-xl border p-4',
|
'rounded-xl border p-4',
|
||||||
dark
|
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||||
? 'border-slate-700 bg-slate-800/60'
|
|
||||||
: 'border-slate-200 bg-white shadow-sm',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<p className={['text-xs font-medium', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
<p className={['text-xs font-medium', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{card.label}</p>
|
||||||
{card.label}
|
|
||||||
</p>
|
|
||||||
<p
|
<p
|
||||||
className={[
|
className={[
|
||||||
'mt-1 text-xl font-semibold tracking-tight',
|
'mt-1 text-xl font-semibold tracking-tight',
|
||||||
card.accent
|
card.accent ? (dark ? 'text-indigo-400' : 'text-indigo-600') : dark ? 'text-slate-100' : 'text-slate-900',
|
||||||
? dark
|
|
||||||
? 'text-indigo-400'
|
|
||||||
: 'text-indigo-600'
|
|
||||||
: dark
|
|
||||||
? 'text-slate-100'
|
|
||||||
: 'text-slate-900',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{card.value}
|
{card.value}
|
||||||
|
|||||||
@@ -26,15 +26,27 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
|||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
|
<div
|
||||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>充值排行榜 (Top 10)</h3>
|
className={[
|
||||||
|
'rounded-xl border p-6',
|
||||||
|
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||||
|
充值排行榜 (Top 10)
|
||||||
|
</h3>
|
||||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['rounded-xl border', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-xl border',
|
||||||
|
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
<h3 className={['px-6 pt-5 pb-2 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
<h3 className={['px-6 pt-5 pb-2 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||||
充值排行榜 (Top 10)
|
充值排行榜 (Top 10)
|
||||||
</h3>
|
</h3>
|
||||||
@@ -56,7 +68,9 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
|||||||
<tr key={entry.userId} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
<tr key={entry.userId} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
||||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
{rankStyle ? (
|
{rankStyle ? (
|
||||||
<span className={`inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${dark ? rankStyle.dark : rankStyle.light}`}>
|
<span
|
||||||
|
className={`inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${dark ? rankStyle.dark : rankStyle.light}`}
|
||||||
|
>
|
||||||
{rank}
|
{rank}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -71,7 +85,9 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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'}`}
|
||||||
|
>
|
||||||
¥{entry.totalAmount.toLocaleString()}
|
¥{entry.totalAmount.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className={tdMuted}>{entry.orderCount}</td>
|
<td className={tdMuted}>{entry.orderCount}</td>
|
||||||
|
|||||||
@@ -84,7 +84,10 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
|||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-lg font-bold">订单详情</h3>
|
<h3 className="text-lg font-bold">订单详情</h3>
|
||||||
<button onClick={onClose} className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}>
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}
|
||||||
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,16 +106,31 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
|||||||
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>审计日志</h4>
|
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>审计日志</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{order.auditLogs.map((log) => (
|
{order.auditLogs.map((log) => (
|
||||||
<div key={log.id} className={`rounded-lg border p-3 ${dark ? 'border-slate-600 bg-slate-700/60' : 'border-gray-100 bg-gray-50'}`}>
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className={`rounded-lg border p-3 ${dark ? 'border-slate-600 bg-slate-700/60' : 'border-gray-100 bg-gray-50'}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">{log.action}</span>
|
<span className="text-sm font-medium">{log.action}</span>
|
||||||
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{new Date(log.createdAt).toLocaleString('zh-CN')}</span>
|
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||||
|
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{log.detail && <div className={`mt-1 break-all text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>{log.detail}</div>}
|
{log.detail && (
|
||||||
{log.operator && <div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>操作者: {log.operator}</div>}
|
<div className={`mt-1 break-all text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||||
|
{log.detail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.operator && (
|
||||||
|
<div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||||
|
操作者: {log.operator}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{order.auditLogs.length === 0 && <div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>暂无日志</div>}
|
{order.auditLogs.length === 0 && (
|
||||||
|
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>暂无日志</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
|||||||
return (
|
return (
|
||||||
<tr key={order.id} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
<tr key={order.id} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
||||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
<button onClick={() => onViewDetail(order.id)} className={dark ? 'text-indigo-400 hover:underline' : 'text-blue-600 hover:underline'}>
|
<button
|
||||||
|
onClick={() => onViewDetail(order.id)}
|
||||||
|
className={dark ? 'text-indigo-400 hover:underline' : 'text-blue-600 hover:underline'}
|
||||||
|
>
|
||||||
{order.id.slice(0, 12)}...
|
{order.id.slice(0, 12)}...
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -79,21 +82,27 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
|||||||
</td>
|
</td>
|
||||||
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
||||||
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
||||||
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>¥{order.amount.toFixed(2)}</td>
|
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>
|
||||||
|
¥{order.amount.toFixed(2)}
|
||||||
|
</td>
|
||||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${dark ? statusInfo.dark : statusInfo.light}`}>
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${dark ? statusInfo.dark : statusInfo.light}`}
|
||||||
|
>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={tdMuted}>
|
<td className={tdMuted}>
|
||||||
{order.paymentType === 'alipay' ? '支付宝' : order.paymentType === 'wechat' ? '微信支付' : order.paymentType === 'stripe' ? 'Stripe' : order.paymentType}
|
{order.paymentType === 'alipay'
|
||||||
</td>
|
? '支付宝'
|
||||||
<td className={tdMuted}>
|
: order.paymentType === 'wechat'
|
||||||
{order.srcHost || '-'}
|
? '微信支付'
|
||||||
</td>
|
: order.paymentType === 'stripe'
|
||||||
<td className={tdMuted}>
|
? 'Stripe'
|
||||||
{new Date(order.createdAt).toLocaleString('zh-CN')}
|
: order.paymentType}
|
||||||
</td>
|
</td>
|
||||||
|
<td className={tdMuted}>{order.srcHost || '-'}</td>
|
||||||
|
<td className={tdMuted}>{new Date(order.createdAt).toLocaleString('zh-CN')}</td>
|
||||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{order.rechargeRetryable && (
|
{order.rechargeRetryable && (
|
||||||
@@ -119,7 +128,9 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{orders.length === 0 && <div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>暂无订单</div>}
|
{orders.length === 0 && (
|
||||||
|
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>暂无订单</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,30 @@ const TYPE_CONFIG: Record<string, { label: string; light: string; dark: string }
|
|||||||
export default function PaymentMethodChart({ data, dark }: PaymentMethodChartProps) {
|
export default function PaymentMethodChart({ data, dark }: PaymentMethodChartProps) {
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
|
<div
|
||||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>支付方式分布</h3>
|
className={[
|
||||||
|
'rounded-xl border p-6',
|
||||||
|
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||||
|
支付方式分布
|
||||||
|
</h3>
|
||||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
|
<div
|
||||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>支付方式分布</h3>
|
className={[
|
||||||
|
'rounded-xl border p-6',
|
||||||
|
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||||
|
支付方式分布
|
||||||
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.map((method) => {
|
{data.map((method) => {
|
||||||
const config = TYPE_CONFIG[method.paymentType] || {
|
const config = TYPE_CONFIG[method.paymentType] || {
|
||||||
@@ -46,7 +60,11 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
|||||||
¥{method.amount.toLocaleString()} · {method.percentage}%
|
¥{method.amount.toLocaleString()} · {method.percentage}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={['h-3 w-full overflow-hidden rounded-full', dark ? 'bg-slate-700' : 'bg-slate-100'].join(' ')}>
|
<div
|
||||||
|
className={['h-3 w-full overflow-hidden rounded-full', dark ? 'bg-slate-700' : 'bg-slate-100'].join(
|
||||||
|
' ',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={['h-full rounded-full transition-all', dark ? config.dark : config.light].join(' ')}
|
className={['h-full rounded-full transition-all', dark ? config.dark : config.light].join(' ')}
|
||||||
style={{ width: `${method.percentage}%` }}
|
style={{ width: `${method.percentage}%` }}
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ function getCommonParams(appId: string): Record<string, string> {
|
|||||||
|
|
||||||
function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
|
function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
|
||||||
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY || !env.ALIPAY_PUBLIC_KEY) {
|
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY || !env.ALIPAY_PUBLIC_KEY) {
|
||||||
throw new Error(
|
throw new Error('Alipay environment variables (ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, ALIPAY_PUBLIC_KEY) are required');
|
||||||
'Alipay environment variables (ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, ALIPAY_PUBLIC_KEY) are required',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return env as typeof env & {
|
return env as typeof env & {
|
||||||
ALIPAY_APP_ID: string;
|
ALIPAY_APP_ID: string;
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ export class AlipayProvider implements PaymentProvider {
|
|||||||
tradeNo: params.trade_no || '',
|
tradeNo: params.trade_no || '',
|
||||||
orderId: params.out_trade_no || '',
|
orderId: params.out_trade_no || '',
|
||||||
amount: parseFloat(params.total_amount || '0'),
|
amount: parseFloat(params.total_amount || '0'),
|
||||||
status: params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED'
|
status:
|
||||||
? 'success'
|
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||||
: 'failed',
|
|
||||||
rawData: params,
|
rawData: params,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ const envSchema = z.object({
|
|||||||
PAYMENT_PROVIDERS: z
|
PAYMENT_PROVIDERS: z
|
||||||
.string()
|
.string()
|
||||||
.default('')
|
.default('')
|
||||||
.transform((v) => v.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)),
|
.transform((v) =>
|
||||||
|
v
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
|
||||||
// ── Easy-Pay(PAYMENT_PROVIDERS 含 easypay 时必填) ──
|
// ── Easy-Pay(PAYMENT_PROVIDERS 含 easypay 时必填) ──
|
||||||
EASY_PAY_PID: optionalTrimmedString,
|
EASY_PAY_PID: optionalTrimmedString,
|
||||||
@@ -55,9 +60,21 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
// 每日各渠道全平台总限额,可选覆盖(0 = 不限制)。
|
// 每日各渠道全平台总限额,可选覆盖(0 = 不限制)。
|
||||||
// 未设置时由各 PaymentProvider.defaultLimits 提供默认值。
|
// 未设置时由各 PaymentProvider.defaultLimits 提供默认值。
|
||||||
MAX_DAILY_AMOUNT_ALIPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
|
MAX_DAILY_AMOUNT_ALIPAY: z
|
||||||
MAX_DAILY_AMOUNT_WXPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
|
.string()
|
||||||
MAX_DAILY_AMOUNT_STRIPE: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
|
.optional()
|
||||||
|
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||||
|
.pipe(z.number().min(0).optional()),
|
||||||
|
MAX_DAILY_AMOUNT_WXPAY: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||||
|
.pipe(z.number().min(0).optional()),
|
||||||
|
MAX_DAILY_AMOUNT_STRIPE: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||||
|
.pipe(z.number().min(0).optional()),
|
||||||
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
|
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
|
||||||
|
|
||||||
ADMIN_TOKEN: z.string().min(1),
|
ADMIN_TOKEN: z.string().min(1),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class EasyPayProvider implements PaymentProvider {
|
|||||||
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
|
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
|
||||||
readonly defaultLimits = {
|
readonly defaultLimits = {
|
||||||
alipay: { singleMax: 1000, dailyMax: 10000 },
|
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||||
wxpay: { singleMax: 1000, dailyMax: 10000 },
|
wxpay: { singleMax: 1000, dailyMax: 10000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||||
|
|||||||
@@ -33,6 +33,6 @@ export function getMethodFeeRate(paymentType: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
|
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
|
||||||
if (feeRate <= 0) return rechargeAmount;
|
if (feeRate <= 0) return rechargeAmount;
|
||||||
const feeAmount = Math.ceil(rechargeAmount * feeRate / 100 * 100) / 100;
|
const feeAmount = Math.ceil(((rechargeAmount * feeRate) / 100) * 100) / 100;
|
||||||
return Math.round((rechargeAmount + feeAmount) * 100) / 100;
|
return Math.round((rechargeAmount + feeAmount) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,7 @@ export interface MethodLimitStatus {
|
|||||||
* 批量查询多个支付渠道的今日使用情况。
|
* 批量查询多个支付渠道的今日使用情况。
|
||||||
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
|
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
|
||||||
*/
|
*/
|
||||||
export async function queryMethodLimits(
|
export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<string, MethodLimitStatus>> {
|
||||||
paymentTypes: string[],
|
|
||||||
): Promise<Record<string, MethodLimitStatus>> {
|
|
||||||
const todayStart = new Date();
|
const todayStart = new Date();
|
||||||
todayStart.setUTCHours(0, 0, 0, 0);
|
todayStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
@@ -80,9 +78,7 @@ export async function queryMethodLimits(
|
|||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const usageMap = Object.fromEntries(
|
const usageMap = Object.fromEntries(usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]));
|
||||||
usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result: Record<string, MethodLimitStatus> = {};
|
const result: Record<string, MethodLimitStatus> = {};
|
||||||
for (const type of paymentTypes) {
|
for (const type of paymentTypes) {
|
||||||
|
|||||||
@@ -65,11 +65,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
|
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
|
||||||
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
|
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
|
||||||
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
|
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
|
||||||
throw new OrderError(
|
throw new OrderError('DAILY_LIMIT_EXCEEDED', `今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`, 429);
|
||||||
'DAILY_LIMIT_EXCEEDED',
|
|
||||||
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`,
|
|
||||||
429,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,7 +601,12 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await subtractBalance(order.userId, rechargeAmount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
|
await subtractBalance(
|
||||||
|
order.userId,
|
||||||
|
rechargeAmount,
|
||||||
|
`sub2apipay refund order:${order.id}`,
|
||||||
|
`sub2apipay:refund:${order.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
await prisma.order.update({
|
await prisma.order.update({
|
||||||
where: { id: input.orderId },
|
where: { id: input.orderId },
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function initPaymentProviders(): void {
|
|||||||
if (unsupported.length > 0) {
|
if (unsupported.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`ENABLED_PAYMENT_TYPES 含 [${unsupported.join(', ')}],但没有对应的 PAYMENT_PROVIDERS 注册。` +
|
`ENABLED_PAYMENT_TYPES 含 [${unsupported.join(', ')}],但没有对应的 PAYMENT_PROVIDERS 注册。` +
|
||||||
`请检查 PAYMENT_PROVIDERS 配置`,
|
`请检查 PAYMENT_PROVIDERS 配置`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyNotification(rawBody: string | Buffer, headers: Record<string, string>): Promise<PaymentNotification | null> {
|
async verifyNotification(
|
||||||
|
rawBody: string | Buffer,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<PaymentNotification | null> {
|
||||||
const stripe = this.getClient();
|
const stripe = this.getClient();
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
if (!env.STRIPE_WEBHOOK_SECRET) throw new Error('STRIPE_WEBHOOK_SECRET not configured');
|
if (!env.STRIPE_WEBHOOK_SECRET) throw new Error('STRIPE_WEBHOOK_SECRET not configured');
|
||||||
|
|||||||
Reference in New Issue
Block a user