diff --git a/docs/Phase2-分销商品与支付通道验收.md b/docs/Phase2-分销商品与支付通道验收.md new file mode 100644 index 0000000..0266cdf --- /dev/null +++ b/docs/Phase2-分销商品与支付通道验收.md @@ -0,0 +1,116 @@ +# Phase 2 验收报告(分销商品与支付通道) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:后台分销商品(收货商品 + 账号展示)、站点支付通道 +> 结论:**本切片通过,页面已接真实接口,typecheck/build 通过;未调用任何商品或支付写接口** + +--- + +## 1. 页面与路由 + +| 页面 | 路由 | 文件 | +| --- | --- | --- | +| 分销商品 | `/console-jy8k3m/distribution/listings` | `frontend/src/features/admin/AdminDistributionListingsPage.tsx` | +| 支付通道 | `/console-jy8k3m/distribution/payment-channels` | `frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx` | + +菜单权限: + +- 分销商品查看:`distribution:account_view` +- 账号展示/上下架/加价:`distribution:account_display` +- 支付通道查看:`distribution:payment_view` +- 支付通道绑定/解绑:`distribution:payment_manage` +- 支付配置下拉:额外需要 `payment_config:list`;缺失时降级为手动输入配置 ID + +## 2. 接口 + +| 能力 | 接口 | 权限码 | +| --- | --- | --- | +| 收货商品列表 | `GET /api/admin/distribution/received-listings?site_id=` | `distribution:account_view` | +| 收货商品上下架 | `PUT /api/admin/distribution/received-listings/:id/status` | `distribution:account_display` | +| 账号展示列表 | `GET /api/admin/distribution/accounts?site_id=` | `distribution:account_view` | +| 单个账号展示状态 | `PUT /api/admin/distribution/accounts/:id/display` | `distribution:account_display` | +| 单个账号加价 | `PUT /api/admin/distribution/accounts/:id/markup` | `distribution:account_display` | +| 批量展示状态 | `POST /api/admin/distribution/accounts/batch-display?site_id=` | `distribution:account_display` | +| 批量加价 | `POST /api/admin/distribution/accounts/batch-markup?site_id=` | `distribution:account_display` | +| 支付通道列表 | `GET /api/admin/distribution/payment-channels?site_id=` | `distribution:payment_view` | +| 绑定支付通道 | `POST /api/admin/distribution/payment-channels?site_id=` | `distribution:payment_manage` | +| 解绑支付通道 | `DELETE /api/admin/distribution/payment-channels/:id` | `distribution:payment_manage` | +| 主站支付配置下拉 | `GET /api/admin/payment-configs` | `payment_config:list` | + +批量接口的站点由 query `site_id` 解析,已确认不能只放在请求体里。 + +## 3. 分销商品页 + +页面使用两个 Tab,共用站点选择器: + +### 3.1 收货商品 + +- 筛选:关键字(商品号/标题/号主手机号/昵称)、展示状态、审核状态。 +- 列表:商品标题/编号/区服/段位、号主、展示价/卖家价/押金/哈夫币、卖家/买家比例、展示与审核状态、审核原因、上架/创建时间。 +- 操作:上架/下架,二次确认后调用收货商品状态接口。 +- 服务端分页,保留上一页数据避免闪空。 + +### 3.2 账号展示 + +- 展示状态筛选与客户端关键字过滤(商品号/标题/区服/段位/平台)。 +- 列表:商品、展示状态、商品状态、交易中标记、基础/站点比例、调整点数、基础价/展示价/加价/押金、来源渠道、展示 ID。 +- 单账号操作:展示/隐藏、调整加价。 +- 批量操作:批量展示、批量隐藏、批量设置加价。 +- 加价弹窗明确说明 `shelf_ratio_delta` 为比例调整点,提交值会覆盖原值;批量加价同样覆盖而非累加。 +- 账户展示接口无服务端分页,前端在已加载数据上过滤与分页。 + +## 4. 支付通道页 + +- 站点选择器;列表按优先级升序展示站点已绑定通道。 +- 列表展示配置名称、服务商、支付方式、配置 ID、环境/状态、主站默认标记、优先级。 +- 绑定弹窗: + - 有 `payment_config:list` 时提供支付配置下拉,自动回填配置 ID + - 无权限或下拉失败时仍可手动输入配置 ID + - 优先级 0–9999 +- 解绑需二次确认;已支付订单不受影响。 +- 页面提示“未绑定时回退主站默认支付配置”。 + +## 5. 共用改造 + +- `frontend/src/features/admin/types.ts`:新增收货商品、账号展示、支付通道、支付配置选项类型。 +- `frontend/src/features/admin/api.ts`:新增商品、账号展示、支付通道相关接口封装。 +- `frontend/src/app/AppRouter.tsx`:新增 `distribution/listings`、`distribution/payment-channels`。 +- `frontend/src/layouts/AdminLayout.tsx`:分销菜单新增「分销商品」「支付通道」及页面标题。 +- 复用后台 `DataTable`、`FilterBar`、`StatusTag`、`DistributionSiteSelect`。 + +## 6. 验证结果 + +```text +cd frontend && npx tsc --noEmit 通过 +cd frontend && npm run build 通过(入口 525.25 kB,gzip 168.51 kB) + +GET /console-jy8k3m/distribution/listings 200 +GET /console-jy8k3m/distribution/payment-channels 200 + +GET /api/admin/distribution/received-listings?site_id=1&page=1&page_size=5 + 200,空列表 +GET /api/admin/distribution/accounts?site_id=1 + 200,空列表 +GET /api/admin/distribution/payment-channels?site_id=1 + 200,空列表 +GET /api/admin/payment-configs?page=1&page_size=5 + 200,空列表 +``` + +未调用商品上下架、账号展示/加价、批量操作、支付通道绑定/解绑等写接口;开发库当前没有分销站点与相关数据。 + +## 7. 已知限制 + +1. 账号展示接口本身不分页,页面采用前端过滤与分页;站点账号量很大时需要后端补分页。 +2. 不提供账号同步入口;同步仍由分销后台或既有流程触发。 +3. 支付配置下拉依赖 `payment_config:list`;无权限时只能手动输入配置 ID。 +4. 不提供支付配置的新建/编辑,避免在主站财务能力之外重复实现密钥管理。 +5. 开发库无真实商品/支付配置,空态和参数校验已验证,未做真实上下架与支付路由回归。 + +## 8. 下一步建议 + +1. 分销看板与账号同步等剩余长尾。 +2. 真实站点数据下回归收货商品上下架、账号加价与支付通道优先级。 +3. 订单结算比例全面 bps 化。 +4. 真实订单支付/交接/确认收号/结账/申诉端到端回归。 diff --git a/docs/Phase2-分销用户与站点公告验收.md b/docs/Phase2-分销用户与站点公告验收.md new file mode 100644 index 0000000..182c0bd --- /dev/null +++ b/docs/Phase2-分销用户与站点公告验收.md @@ -0,0 +1,107 @@ +# Phase 2 验收报告(分销用户与站点公告) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:后台分销用户、站点公告 +> 结论:**本切片通过,页面已接真实接口,typecheck/build 通过;写操作未调用** + +--- + +## 1. 页面与路由 + +| 页面 | 路由 | 文件 | +| --- | --- | --- | +| 分销用户 | `/console-jy8k3m/distribution/users` | `frontend/src/features/admin/AdminDistributionUsersPage.tsx` | +| 站点公告 | `/console-jy8k3m/distribution/announcements` | `frontend/src/features/admin/AdminSiteAnnouncementsPage.tsx` | + +菜单权限: + +- 分销用户:`distribution:site_view` +- 站点公告查看:`distribution:announcement_view` +- 站点公告维护:`distribution:announcement_manage` + +## 2. 接口 + +| 能力 | 接口 | 权限码 | +| --- | --- | --- | +| 分销用户列表 | `GET /api/admin/distribution/users?site_id=` | `distribution:site_view` | +| 站点公告列表 | `GET /api/admin/distribution/announcements?site_id=` | `distribution:announcement_view` | +| 新建站点公告 | `POST /api/admin/distribution/announcements?site_id=` | `distribution:announcement_manage` | +| 编辑站点公告 | `PUT /api/admin/distribution/announcements/:id?site_id=` | `distribution:announcement_manage` | +| 发布站点公告 | `POST /api/admin/distribution/announcements/:id/publish?site_id=` | `distribution:announcement_manage` | +| 归档站点公告 | `POST /api/admin/distribution/announcements/:id/archive?site_id=` | `distribution:announcement_manage` | +| 取消归档 | `POST /api/admin/distribution/announcements/:id/unarchive?site_id=` | `distribution:announcement_manage` | +| 删除站点公告 | `DELETE /api/admin/distribution/announcements/:id?site_id=` | `distribution:announcement_manage` | + +站点公告接口必须带 `site_id`;解析逻辑见 `resolveAnnouncementSite`,不能复用公告 `:id` 作为站点 ID。 + +## 3. 分销用户页 + +- 站点选择器复用 `DistributionSiteSelect`,站点 ID 写入 URL。 +- 顶部统计:本站注册人数、成交客户数、筛选结果数。 +- 筛选:手机号/昵称关键字、客户来源(全部/本站注册/本站下单)。 +- 列表: + - 客户头像、昵称、脱敏手机号、用户 ID + - 来源标签(本站注册/本站下单/注册并下单) + - 实名状态与账号状态 + - 有效订单数、累计消费(整数分)、最近下单、注册时间、最近登录 +- 详情抽屉:客户资料、来源、实名/账号状态、交易贡献。 +- 只读页面,不提供冻结、改实名、调钱包等主站用户管理能力,避免越权。 + +## 4. 站点公告页 + +- 站点选择器;公告仅查询当前站点 `distribution_site_id`。 +- 状态筛选:草稿/已发布/已归档。 +- 列表:标题与正文摘要、分类、置顶/重要/弹窗标记、状态、排序权重、浏览量、发布时间、更新时间。 +- 新建/编辑弹窗: + - 标题、分类、排序权重、正文 + - 置顶、重要公告、弹窗公告开关 + - 新建默认草稿,发布需显式操作 +- 操作:发布、归档、取消归档、编辑、删除,均有二次确认。 +- 详情抽屉展示完整正文、标记、分类、浏览量、发布/更新时间。 +- 公告正文按纯文本展示,避免直接渲染 HTML。 + +## 5. 共用改造 + +- `frontend/src/features/admin/types.ts`:新增分销用户、站点公告类型。 +- `frontend/src/features/admin/api.ts`:新增用户列表、公告列表/新建/编辑/发布/归档/取消归档/删除封装。 +- `frontend/src/app/AppRouter.tsx`:新增 `distribution/users`、`distribution/announcements`,并恢复被误删的 `distribution/sms` 路由。 +- `frontend/src/layouts/AdminLayout.tsx`:分销菜单新增「分销用户」「站点公告」及页面标题。 +- `frontend/src/styles/admin-distribution.css`:新增客户单元格与头像样式。 + +## 6. 验证结果 + +```text +cd frontend && npx tsc --noEmit 通过 +cd frontend && npm run build 通过(入口 524.31 kB,gzip 168.32 kB) + +GET /console-jy8k3m/distribution/users 200 +GET /console-jy8k3m/distribution/announcements 200 +GET /console-jy8k3m/distribution/sms 200 + +GET /api/admin/distribution/users?site_id=1&page=1&page_size=5 + 200,空列表,total_users=0,order_users=0 +GET /api/admin/distribution/users?page=1 + 404,未指定站点时按站点不存在处理 +GET /api/admin/distribution/announcements?site_id=1&page=1&page_size=5 + 200,空列表 +GET /api/admin/distribution/announcements?page=1 + 400,未指定分销站点 +``` + +未调用公告新建/编辑/发布/归档/删除等写接口;开发库当前没有分销站点与站点公告数据。 + +## 7. 已知限制 + +1. 分销用户只读,不提供冻结、改实名、调钱包等操作;这些仍走主站用户管理。 +2. 公告正文按纯文本展示,不支持富文本/HTML 渲染。 +3. 公告列表无关键字搜索,仅按状态与分页;后端当前也只支持状态筛选。 +4. 站点公告与主站公告是两套数据,公告模式由站点 `announcement_mode` 控制。 +5. 开发库无真实站点数据,空态与参数校验已验证,未做真实公告发布后的前台展示回归。 + +## 8. 下一步建议 + +1. 账号展示/收货商品与支付通道等剩余分销长尾页面。 +2. 真实站点数据下回归用户来源统计与站点公告发布/弹窗。 +3. 订单结算比例全面 bps 化。 +4. 真实订单支付/交接/确认收号/结账/申诉端到端回归。 diff --git a/docs/Phase2-分销订单与结算批次验收.md b/docs/Phase2-分销订单与结算批次验收.md new file mode 100644 index 0000000..4bee079 --- /dev/null +++ b/docs/Phase2-分销订单与结算批次验收.md @@ -0,0 +1,111 @@ +# Phase 2 验收报告(分销订单与结算批次) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:后台分销订单、结算批次 +> 结论:**本切片通过,页面已接真实接口,typecheck/build 通过;未触发结算、释放、作废等写操作** + +--- + +## 1. 页面与路由 + +| 页面 | 路由 | 文件 | +| --- | --- | --- | +| 分销订单 | `/console-jy8k3m/distribution/orders` | `frontend/src/features/admin/AdminDistributionOrdersPage.tsx` | +| 结算批次 | `/console-jy8k3m/distribution/settlements` | `frontend/src/features/admin/AdminDistributionSettlementsPage.tsx` | + +菜单权限: + +- 分销订单:`distribution:order_view` +- 结算批次:`distribution:settlement_view` +- 生成人工批次、释放入账、作废:`distribution:settlement_manage` + +## 2. 接口 + +| 能力 | 接口 | 权限码 | +| --- | --- | --- | +| 分销订单列表 | `GET /api/admin/distribution/orders` | `distribution:order_view` | +| 分销订单详情 | `GET /api/admin/distribution/orders/:id` | `distribution:order_view` | +| 分销财务快照 | `POST /api/admin/distribution/orders/:id/financial-snapshot` | `distribution:settlement_manage` | +| 结算批次列表 | `GET /api/admin/distribution/settlement-batches?site_id=` | `distribution:settlement_view` | +| 生成人工批次 | `POST /api/admin/distribution/settlement-batches` | `distribution:settlement_manage` | +| 批次明细 | `GET /api/admin/distribution/settlement-batches/:id/items` | `distribution:settlement_view` | +| 释放入账 | `POST /api/admin/distribution/settlement-batches/:id/settle` | `distribution:settlement_manage` | +| 作废批次 | `POST /api/admin/distribution/settlement-batches/:id/cancel` | `distribution:settlement_manage` | + +本切片未接财务快照补录入口,避免在未确认业务口径时直接生成资金快照。 + +## 3. 分销订单页 + +- 筛选:站点、分销模式、订单状态、释放状态、关键字(订单号/商品号/标题/租客手机号)。 +- 列表: + - 订单号、商品标题、商品编号 + - 站点与分销模式、分站角色 + - 净付款/实付/退款 + - 利润基数/服务费/加价 + - 分站收益/加价分成/利润分成 + - 订单状态与退款状态 + - 分销入账状态与预计释放时间 +- 详情抽屉: + - 订单与站点:订单号、站点、模式、商品、游戏区服、平台、租用时长、租客、下单/起租时间 + - 金额快照:实付、退款、净付款、支付手续费、服务费、加价、税费、分账基数、取整调整、押金、号主租金、基础价 + - 分销收益:分站收益、角色、加价/分成拆解、释放状态、释放时间、履约结算、交接状态、退款状态 +- 金额全部以整数分展示,使用 `formatMoney(cents)`。 + +## 4. 结算批次页 + +- 站点选择器复用 `DistributionSiteSelect`,站点 ID 写入 URL。 +- 顶部汇总:批次数、待入账数量、待入账金额、已入账金额。 +- 批次列表: + - 批次号与类型(`DA` 自动对账、`DS` 人工批次) + - 对账周期 + - 入账金额 + - 状态(待入账/已释放入账/已作废) + - 操作:明细、释放入账、作废 +- 生成人工批次: + - 使用带时间的周期选择器,明确左闭右开口径 + - 提示人工批次绕过冷却期 + - 生成后仍需二次确认才释放入账 +- 明细弹窗:订单 ID、分成角色、入账金额、明细状态。 +- 空批次(金额 0)禁用释放入账,可作废后重新生成。 +- 释放/作废均有二次确认;写操作未在验收中调用。 + +## 5. 共用改造 + +- `frontend/src/features/admin/types.ts`:新增分销订单、结算批次、结算明细类型。 +- `frontend/src/features/admin/api.ts`:新增订单列表/详情、批次列表/创建/明细/释放/作废封装。 +- `frontend/src/app/AppRouter.tsx`:新增 `distribution/orders`、`distribution/settlements`。 +- `frontend/src/layouts/AdminLayout.tsx`:分销菜单新增「分销订单」「结算批次」及页面标题。 +- `frontend/src/styles/admin-distribution.css`:新增加批次明细头部间距。 + +## 6. 验证结果 + +```text +cd frontend && npx tsc --noEmit 通过 +cd frontend && npm run build 通过(入口 523.44 kB,gzip 168.11 kB) + +GET /console-jy8k3m/distribution/orders 200 +GET /console-jy8k3m/distribution/settlements 200 + +GET /api/admin/distribution/orders?page=1&page_size=5 200,空列表 +GET /api/admin/distribution/orders/1 404,开发库无该订单 +GET /api/admin/distribution/settlement-batches?site_id=1 200,空列表 +GET /api/admin/distribution/settlement-batches/1/items 404,开发库无该批次 +``` + +未调用人工批次创建、释放入账、作废、财务快照等写接口;开发库当前也没有分销站点与分销订单数据。 + +## 7. 已知限制 + +1. 不提供财务快照补录入口;需要补录时应先确认规则版本、参与方与金额口径。 +2. 结算批次列表不分页,按站点返回全部批次;数据量增大后再补分页。 +3. 订单详情只展示订单级快照,不展示逐参与方明细;三方分账需要更细粒度时可另开接口。 +4. 批次释放/作废依赖后端实时复核,前端只做二次确认与错误提示。 +5. 开发库无真实分销订单,列表空态、详情 404、批次空态已验证,未做真实批次金额回归。 + +## 8. 下一步建议 + +1. 分销用户、站点公告、账号展示、支付通道等剩余长尾页面。 +2. 真实站点数据下回归订单筛选、批次生成、明细与释放流程。 +3. 订单结算比例全面 bps 化。 +4. 真实订单支付/交接/确认收号/结账/申诉端到端回归。 diff --git a/docs/Phase2-分销长尾与站点钱包验收.md b/docs/Phase2-分销长尾与站点钱包验收.md new file mode 100644 index 0000000..531802f --- /dev/null +++ b/docs/Phase2-分销长尾与站点钱包验收.md @@ -0,0 +1,130 @@ +# Phase 2 验收报告(分销长尾:钱包、短信、邀请、二维码池、规则版本) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:后台分销长尾 5 个页面 +> 结论:**本切片通过,页面已接真实接口,前端构建通过;未执行任何资金类写操作** + +--- + +## 1. 页面与路由 + +| 页面 | 路由 | 文件 | +| --- | --- | --- | +| 站点钱包 | `/console-jy8k3m/distribution/wallet` | `frontend/src/features/admin/AdminDistributionWalletPage.tsx` | +| 站点短信 | `/console-jy8k3m/distribution/sms` | `frontend/src/features/admin/AdminDistributionSMSPage.tsx` | +| 邀请返利 | `/console-jy8k3m/distribution/invitation` | `frontend/src/features/admin/AdminDistributionInvitationPage.tsx` | +| 二维码池 | `/console-jy8k3m/distribution/qrcodes` | `frontend/src/features/admin/AdminDistributionQrCodesPage.tsx` | +| 规则版本 | `/console-jy8k3m/distribution/rules` | `frontend/src/features/admin/AdminDistributionRulesPage.tsx` | + +公共站点选择器: + +- `frontend/src/features/admin/components/DistributionSiteSelect.tsx` +- 站点 ID 写入 URL `?site_id=`,刷新与分享不丢上下文。 +- 未指定或无效站点时自动回退第一个可见站点;规则版本页关闭自动改写 URL。 + +## 2. 接口与权限 + +| 能力 | 接口 | 权限码 | +| --- | --- | --- | +| 站点钱包 | `GET /api/admin/distribution/sites/:id/wallet` | `distribution:settlement_view` | +| 提现记录 | `GET /api/admin/distribution/sites/:id/withdrawals` | `distribution:settlement_view` | +| 申请提现 | `POST /api/admin/distribution/sites/:id/withdrawals` | `distribution:settlement_manage` | +| 短信配置读取 | `GET /api/admin/distribution/sites/:id/sms-config` | `distribution:site_view` | +| 短信配置保存 | `PUT /api/admin/distribution/sites/:id/sms-config` | `distribution:site_manage` | +| 短信测试 | `POST /api/admin/distribution/sites/:id/sms-config/test` | `distribution:site_manage` | +| 邀请配置读取 | `GET /api/admin/distribution/sites/:id/invitation-config` | `distribution:site_view` | +| 邀请配置保存 | `PUT /api/admin/distribution/sites/:id/invitation-config` | `distribution:site_manage` | +| 二维码列表/统计 | `GET /api/admin/distribution/sites/:id/qrcodes`、`.../qrcodes/stats` | `qrcode:view` | +| 二维码批量入池 | `POST /api/admin/distribution/sites/:id/qrcodes/batch` | `qrcode:manage` | +| 二维码批量删除 | `POST /api/admin/distribution/sites/:id/qrcodes/batch-delete` | `qrcode:manage` | +| 二维码编辑/删除 | `PATCH/DELETE /api/admin/distribution/sites/:id/qrcodes/:qrcodeId` | `qrcode:manage` | +| 规则列表 | `GET /api/admin/distribution/rules` | `distribution:rule_view` | +| 新建规则 | `POST /api/admin/distribution/rules` | `distribution:rule_manage` | +| 规则状态 | `PUT /api/admin/distribution/rules/:id/status` | `distribution:rule_manage` | + +## 3. 页面能力 + +### 3.1 站点钱包 + +- 余额卡片:可用余额、冻结余额、待释放收益、累计结算、累计提现,金额全部以整数分展示。 +- 提现申请表:金额按元输入,`parseYuanToCent` 转整数分后提交;校验金额大于 0 且不超过可用余额。 +- 收款方式支持支付宝/银行卡,银行卡展开开户银行与支行。 +- 提交前二次确认,明确提示「从可用余额转入冻结余额」。 +- 提现记录分页,展示单号、金额/手续费/到账、收款账户、状态、备注、申请/打款时间。 +- **后端没有站点提现审核通过/拒绝接口,本页不提供审核按钮**;仅做申请与记录查询。 + +### 3.2 站点短信 + +- 启用开关、服务商(阿里云)、AccessKey ID、AccessKey Secret、接入地址、短信签名、登录模板。 +- Secret 只读掩码:显示 `masked_secret` 与 `has_secret`,留空保存表示不修改。 +- 三个业务节点模板:账号审核未通过、账号被租用需辅助登录、上架多日未成交降价建议;留空表示回退主站模板。 +- 保存时 `business_templates` 整体覆盖,空值由后端归一化为回退主站。 +- 测试短信使用当前表单(含未保存修改),不写入站点配置。 + +### 3.3 邀请返利 + +- 启用开关与首月/次月/第三个月起三档返利比例。 +- 表单按百分比输入,提交时转 0–1 小数,符合后端 `UpdateConfigRequest` 口径。 +- 比例预览与规则说明:实名后 30 天内 / 31–60 天 / 61 天以后。 + +### 3.4 二维码池 + +- 统计卡片:待用、已发放、已停用、总计。 +- 筛选:关键字、状态、绑定群、过期状态。 +- 列表:缩略图预览、分组/备注、状态、绑定会话、企微改名、有效期、创建时间。 +- 批量入池:单次最多 20 张,原图上传不压缩,逐张可填群名,批次备注统一写入。 +- 支持停用/启用、编辑(群名、备注、状态、企微改名、过期时间/清除过期)、单删与多选批量删除。 +- 已发放二维码不能改回待用,后端拒绝时前端展示错误。 + +### 3.5 规则版本 + +- 支持全局/指定站点、四种分账模式:分站收号、平台收号、三方分账、分站自营。 +- 新建规则默认草稿;比例按百分数填写,服务费支持比例或固定分。 +- 列表展示作用域、模式、分账比例、服务费、支付费率、税费、状态与生效时间。 +- 状态操作:启用、停用、停用后转草稿,均有二次确认。 +- 不提供规则内容编辑接口,已生效版本通过新建版本演进。 + +## 4. 共用改造 + +- `frontend/src/features/admin/types.ts`:新增分销钱包、提现、短信、邀请、二维码、规则类型。 +- `frontend/src/features/admin/api.ts`:新增对应只读与写接口封装,写接口未在验收中调用。 +- `frontend/src/features/admin/components/DataTable.tsx`:新增可选 `rowSelection`,供二维码池批量选择。 +- `frontend/src/shared/api/files.ts`:新增 `uploadAdminFile`,走 `adminClient` 的 HttpOnly Cookie,调用 `/admin/files/upload`。 +- `frontend/src/styles/admin-distribution.css`:新增站点选择器、钱包卡片、表单提示、邀请预览、二维码上传与待入池列表样式。 +- `frontend/src/styles/global.css`:在 `admin-shell.css` 之后引入分销样式。 + +## 5. 验证结果 + +```text +cd frontend && npx tsc --noEmit 通过 +cd frontend && npm run build 通过(入口 522.63 kB,gzip 167.98 kB) + +GET /console-jy8k3m/distribution/wallet 200 +GET /console-jy8k3m/distribution/sms 200 +GET /console-jy8k3m/distribution/invitation 200 +GET /console-jy8k3m/distribution/qrcodes 200 +GET /console-jy8k3m/distribution/rules 200 + +GET /api/admin/distribution/rules 200,返回规则版本 +GET /api/admin/distribution/sites 200,开发库当前 0 个站点 +GET /api/admin/distribution/sites/1/withdrawals 200,空列表 +``` + +开发库当前没有分销站点,因此站点钱包、短信、二维码的站点级 GET 返回 404「分销资源不存在」属于数据缺失,不是路由缺失。未调用提现申请、短信保存/测试、二维码写入、规则写入等写接口。 + +## 6. 已知限制 + +1. 站点提现后端只有申请与列表,没有审核通过/拒绝接口,本页不伪造审核能力;审核仍需另开后端切片。 +2. 短信服务商当前只开放阿里云,`mock` 仅在非生产环境允许且未在界面暴露。 +3. 二维码池未接 OCR 自动识别群名,群名可手动填写;后续可复用 `/chat/qrcodes/ocr` 能力。 +4. 规则版本只支持新建与状态切换,不支持原地编辑已生效规则,避免历史订单快照失真。 +5. 邀请返利比例后端仍是 0–1 浮点字段,前端仅在输入/展示边界转换,未改后端存储口径。 +6. 开发库无站点,站点级页面的空态与 404 分支已验证,但未做真实站点数据下的视觉回归。 + +## 7. 下一步建议 + +1. 分销订单、结算批次、分销用户、站点公告等剩余长尾页面。 +2. 真实站点数据下回归钱包/二维码/短信/邀请四个站点级页面。 +3. 订单结算 `buyer_ratio` / `seller_ratio` 全面 bps 化。 +4. 真实订单支付/交接/确认收号/结账/申诉端到端回归。 diff --git a/docs/项目计划.md b/docs/项目计划.md index e62521d..b95fc6c 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -381,7 +381,7 @@ hfb_jysh/ ### Phase 3:C 端 PC + 移动端(3 周) -> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心、聊天与卖家中心,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心、聊天页与卖家发布/编辑/审核提交已接通,剩余分销长尾能力与真实订单端到端回归。 +> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心、聊天与卖家中心,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心、聊天页与卖家发布/编辑/审核提交已接通,剩余真实订单端到端回归。 - 首页、商品列表、商品详情、撞车商品。 - 登录、注册、找回密码、实名认证。 @@ -395,7 +395,7 @@ hfb_jysh/ ### Phase 4:管理后台核心(4–5 周) -> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置与分销站点;分销长尾能力待迁移。 +> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、分销站点、站点钱包、分销订单、结算批次、分销用户、站点公告、分销商品、支付通道、规则版本、二维码池、站点短信与邀请返利;剩余分销看板、账号同步、主站支付配置管理等细分长尾和真实订单端到端回归。 - 后台登录、工作台、订单列表、订单详情。 - 商品列表、商品详情、审核。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index af4b0d8..15854fc 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -180,6 +180,61 @@ const AdminDistributionSitesPage = lazy(() => default: module.AdminDistributionSitesPage, })) ) +const AdminDistributionWalletPage = lazy(() => + import('@/features/admin/AdminDistributionWalletPage').then(module => ({ + default: module.AdminDistributionWalletPage, + })) +) +const AdminDistributionSMSPage = lazy(() => + import('@/features/admin/AdminDistributionSMSPage').then(module => ({ + default: module.AdminDistributionSMSPage, + })) +) +const AdminDistributionInvitationPage = lazy(() => + import('@/features/admin/AdminDistributionInvitationPage').then(module => ({ + default: module.AdminDistributionInvitationPage, + })) +) +const AdminDistributionQrCodesPage = lazy(() => + import('@/features/admin/AdminDistributionQrCodesPage').then(module => ({ + default: module.AdminDistributionQrCodesPage, + })) +) +const AdminDistributionRulesPage = lazy(() => + import('@/features/admin/AdminDistributionRulesPage').then(module => ({ + default: module.AdminDistributionRulesPage, + })) +) +const AdminDistributionOrdersPage = lazy(() => + import('@/features/admin/AdminDistributionOrdersPage').then(module => ({ + default: module.AdminDistributionOrdersPage, + })) +) +const AdminDistributionSettlementsPage = lazy(() => + import('@/features/admin/AdminDistributionSettlementsPage').then(module => ({ + default: module.AdminDistributionSettlementsPage, + })) +) +const AdminDistributionUsersPage = lazy(() => + import('@/features/admin/AdminDistributionUsersPage').then(module => ({ + default: module.AdminDistributionUsersPage, + })) +) +const AdminSiteAnnouncementsPage = lazy(() => + import('@/features/admin/AdminSiteAnnouncementsPage').then(module => ({ + default: module.AdminSiteAnnouncementsPage, + })) +) +const AdminDistributionListingsPage = lazy(() => + import('@/features/admin/AdminDistributionListingsPage').then(module => ({ + default: module.AdminDistributionListingsPage, + })) +) +const AdminDistributionPaymentChannelsPage = lazy(() => + import('@/features/admin/AdminDistributionPaymentChannelsPage').then(module => ({ + default: module.AdminDistributionPaymentChannelsPage, + })) +) const AdminLayout = lazy(() => import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout })) ) @@ -244,6 +299,32 @@ export function AppRouter() { } /> } /> } /> + } /> + } + /> + } /> + } + /> + } + /> + } + /> + } /> + } /> + } + /> + } /> + } /> () + const [saving, setSaving] = useState(false) + const [savedAt, setSavedAt] = useState() + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:site_view') + ) + const canManage = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:site_manage') + ) + const selection = useDistributionSiteSelection() + const siteId = selection.siteId + + const configQuery = useQuery({ + queryKey: ['admin-distribution-invitation-config', siteId], + queryFn: () => fetchAdminDistributionInvitationConfig(siteId as number), + enabled: canView && Boolean(siteId), + }) + const config = configQuery.data + + useEffect(() => { + if (!config) return + form.setFieldsValue({ + enabled: config.enabled, + first_month_percent: rateToPercent(config.first_month_rate), + second_month_percent: rateToPercent(config.second_month_rate), + subsequent_percent: rateToPercent(config.subsequent_rate), + }) + }, [config, form]) + + useEffect(() => { + setSavedAt(undefined) + }, [siteId]) + + const firstMonthPercent = Form.useWatch('first_month_percent', form) + const secondMonthPercent = Form.useWatch('second_month_percent', form) + const subsequentPercent = Form.useWatch('subsequent_percent', form) + + const maxPercent = useMemo( + () => + Math.max( + Number(firstMonthPercent ?? 0), + Number(secondMonthPercent ?? 0), + Number(subsequentPercent ?? 0) + ), + [firstMonthPercent, secondMonthPercent, subsequentPercent] + ) + + const save = async () => { + if (!siteId) return + const values = await form.validateFields() + setSaving(true) + try { + const result = await updateAdminDistributionInvitationConfig(siteId, { + enabled: values.enabled, + first_month_rate: percentToRate(values.first_month_percent), + second_month_rate: percentToRate(values.second_month_percent), + subsequent_rate: percentToRate(values.subsequent_percent), + }) + queryClient.setQueryData(['admin-distribution-invitation-config', siteId], result) + setSavedAt(new Date().toISOString()) + message.success('邀请返利配置已保存') + } catch (error) { + message.error(errorText(error)) + } finally { + setSaving(false) + } + } + + const refresh = () => { + void configQuery.refetch() + void selection.refetch() + } + + return ( +
+ + {config ? ( + + {config.enabled ? '返利已启用' : '返利已关闭'} + + ) : null} + {savedAt ? 保存于 {formatDateTime(savedAt)} : null} + {config?.updated_at ? ( + 服务端更新 {formatDateTime(config.updated_at)} + ) : null} + + } + extra={ + + } + /> + + {!canView ? ( + + ) : ( + + )} + + {configQuery.isError ? ( + + 重试 + + } + /> + ) : null} + + + + + + 返利比例 + + } + > + + + form={form} + layout="vertical" + disabled={!canManage || saving || !config} + initialValues={{ + enabled: false, + first_month_percent: 0, + second_month_percent: 0, + subsequent_percent: 0, + }} + > + + + + + + + + + + + + + + + + + + + + + + 比例预览 +
+
+ 首月 + {Number(firstMonthPercent ?? 0).toFixed(2)}% +
+
+ 次月 + {Number(secondMonthPercent ?? 0).toFixed(2)}% +
+
+ 第三个月起 + {Number(subsequentPercent ?? 0).toFixed(2)}% +
+
+ 最高比例 + {maxPercent.toFixed(2)}% +
+
+ + + + {!canManage ? ( + + 需要 distribution:site_manage 权限 + + ) : null} + + +
+ + + + +
+
+ 生效前提 + 被邀请人完成实名认证 +
+
+ 首月 + 实名校验后 30 天内 +
+
+ 次月 + 第 31-60 天 +
+
+ 第三个月起 + 第 61 天以后 +
+
+ 返利基数 + 订单单边服务费 +
+
+ + 关闭开关后,新产生的订单不再计入邀请返利;历史返利流水不会回滚。 + +
+ +
+
+ ) +} diff --git a/frontend/src/features/admin/AdminDistributionListingsPage.tsx b/frontend/src/features/admin/AdminDistributionListingsPage.tsx new file mode 100644 index 0000000..f0b876e --- /dev/null +++ b/frontend/src/features/admin/AdminDistributionListingsPage.tsx @@ -0,0 +1,848 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Tabs, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { + EditOutlined, + PlayCircleOutlined, + ReloadOutlined, + StopOutlined, + TagsOutlined, +} from '@ant-design/icons' + +import { + batchUpdateAdminAccountDisplays, + batchUpdateAdminAccountMarkup, + fetchAdminAccountDisplays, + fetchAdminReceivedListings, + updateAdminAccountDisplay, + updateAdminAccountMarkup, + updateAdminReceivedListingStatus, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { + DistributionSiteSelect, + useDistributionSiteSelection, +} from '@/features/admin/components/DistributionSiteSelect' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import { StatusTag } from '@/features/admin/components/StatusTag' +import { readStatusMeta, type StatusMeta } from '@/features/admin/labels' +import type { + AdminAccountDisplay, + AdminReceivedListing, + AdminReceivedListingQuery, +} from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText, maskPhone } from '@/shared/utils/format' + +const listingStatusMap: Record = { + draft: { label: '草稿', tone: 'default' }, + pending_review: { label: '待审核', tone: 'gold' }, + published: { label: '已上架', tone: 'success' }, + offline: { label: '已下架', tone: 'default' }, + rejected: { label: '审核拒绝', tone: 'red' }, + disabled: { label: '已停用', tone: 'default' }, + locked: { label: '已锁定', tone: 'orange' }, +} + +const reviewStatusMap: Record = { + pending: { label: '待审核', tone: 'gold' }, + approved: { label: '已通过', tone: 'success' }, + rejected: { label: '已拒绝', tone: 'red' }, +} + +const displayStatusMap: Record = { + enabled: { label: '展示中', tone: 'success' }, + disabled: { label: '已隐藏', tone: 'default' }, + sold: { label: '已售出', tone: 'blue' }, + offline: { label: '已下架', tone: 'default' }, +} + +const receivedStatusOptions = [ + { value: 'published', label: '展示中' }, + { value: 'offline', label: '已下架' }, +] + +const reviewStatusOptions = Object.entries(reviewStatusMap).map(([value, meta]) => ({ + value, + label: meta.label, +})) + +const displayStatusOptions = Object.entries(displayStatusMap).map(([value, meta]) => ({ + value, + label: meta.label, +})) + +interface MarkupFormValues { + shelf_ratio_delta: number +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +export function AdminDistributionListingsPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [tab, setTab] = useState('received') + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:account_view') + ) + const canManage = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:account_display') + ) + const selection = useDistributionSiteSelection() + const siteId = selection.siteId + + // 收货商品 + const [receivedDraft, setReceivedDraft] = useState({}) + const [receivedFilters, setReceivedFilters] = useState({}) + const [receivedPage, setReceivedPage] = useState(1) + const [receivedPageSize, setReceivedPageSize] = useState(20) + + // 账号展示 + const [accountStatus, setAccountStatus] = useState() + const [accountKeyword, setAccountKeyword] = useState('') + const [accountPage, setAccountPage] = useState(1) + const [accountPageSize, setAccountPageSize] = useState(20) + const [selectedAccountIds, setSelectedAccountIds] = useState([]) + const [markupTarget, setMarkupTarget] = useState() + const [markupForm] = Form.useForm() + const [batchMarkupOpen, setBatchMarkupOpen] = useState(false) + const [batchMarkupForm] = Form.useForm() + const [working, setWorking] = useState(false) + + const receivedQuery = useQuery({ + queryKey: ['admin-received-listings', siteId, receivedFilters, receivedPage, receivedPageSize], + queryFn: () => + fetchAdminReceivedListings(siteId as number, { + ...receivedFilters, + page: receivedPage, + page_size: receivedPageSize, + }), + enabled: canView && Boolean(siteId), + placeholderData: keepPreviousData, + }) + + const accountsQuery = useQuery({ + queryKey: ['admin-account-displays', siteId, accountStatus], + queryFn: () => fetchAdminAccountDisplays(siteId as number, accountStatus), + enabled: canView && Boolean(siteId), + }) + + const receivedRows = receivedQuery.data?.items ?? [] + const accountRows = accountsQuery.data ?? [] + + const filteredAccounts = useMemo(() => { + const keyword = accountKeyword.trim().toLowerCase() + if (!keyword) return accountRows + return accountRows.filter(row => + [row.title, row.listing_no, row.game_name, row.server_region, row.rank_level, row.login_platform] + .filter(Boolean) + .some(value => String(value).toLowerCase().includes(keyword)) + ) + }, [accountKeyword, accountRows]) + + const pagedAccounts = useMemo( + () => + filteredAccounts.slice( + (accountPage - 1) * accountPageSize, + accountPage * accountPageSize + ), + [accountPage, accountPageSize, filteredAccounts] + ) + + const refresh = () => { + void receivedQuery.refetch() + void accountsQuery.refetch() + void selection.refetch() + } + + const changeReceivedStatus = (row: AdminReceivedListing, status: 'published' | 'offline') => { + const label = status === 'published' ? '上架' : '下架' + modal.confirm({ + title: `确认${label}商品「${row.title}」?`, + content: + status === 'offline' + ? '下架后该商品不再在分站展示,已进行中的订单不受影响。' + : '上架后该商品会重新进入分站展示;审核未通过的商品可能被后端拒绝。', + okText: `确认${label}`, + cancelText: '取消', + onOk: async () => { + try { + await updateAdminReceivedListingStatus(row.listing_id, status) + message.success(`商品已${label}`) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-received-listings', siteId] }), + queryClient.invalidateQueries({ queryKey: ['admin-account-displays', siteId] }), + ]) + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const toggleDisplay = (row: AdminAccountDisplay) => { + const next = row.display_status === 'enabled' ? 'disabled' : 'enabled' + const label = next === 'enabled' ? '展示' : '隐藏' + modal.confirm({ + title: `确认${label}商品「${row.title || row.listing_no}」?`, + content: + next === 'enabled' + ? '展示后该账号会出现在分站商品列表。' + : '隐藏后该账号不再在分站展示,不影响已进行中的订单。', + okText: `确认${label}`, + cancelText: '取消', + onOk: async () => { + try { + await updateAdminAccountDisplay(row.id, next) + message.success(`商品已${label}`) + await queryClient.invalidateQueries({ + queryKey: ['admin-account-displays', siteId], + }) + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const openMarkup = (row: AdminAccountDisplay) => { + setMarkupTarget(row) + markupForm.setFieldsValue({ shelf_ratio_delta: row.shelf_ratio_delta }) + } + + const saveMarkup = async () => { + if (!markupTarget) return + const values = await markupForm.validateFields() + setWorking(true) + try { + await updateAdminAccountMarkup(markupTarget.id, values.shelf_ratio_delta) + message.success('账号加价已更新') + setMarkupTarget(undefined) + await queryClient.invalidateQueries({ queryKey: ['admin-account-displays', siteId] }) + } catch (error) { + message.error(errorText(error)) + } finally { + setWorking(false) + } + } + + const batchChangeDisplay = (target: 'enabled' | 'disabled') => { + if (!siteId || selectedAccountIds.length === 0) return + const label = target === 'enabled' ? '展示' : '隐藏' + modal.confirm({ + title: `确认批量${label} ${selectedAccountIds.length} 个账号?`, + content: '批量操作只影响当前选中的账号展示状态。', + okText: `确认${label}`, + cancelText: '取消', + onOk: async () => { + try { + await batchUpdateAdminAccountDisplays(siteId, selectedAccountIds, target) + message.success(`已批量${label}`) + setSelectedAccountIds([]) + await queryClient.invalidateQueries({ + queryKey: ['admin-account-displays', siteId], + }) + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const saveBatchMarkup = async () => { + if (!siteId || selectedAccountIds.length === 0) return + const values = await batchMarkupForm.validateFields() + setWorking(true) + try { + await batchUpdateAdminAccountMarkup( + siteId, + selectedAccountIds, + values.shelf_ratio_delta + ) + message.success('批量加价已更新') + setBatchMarkupOpen(false) + setSelectedAccountIds([]) + await queryClient.invalidateQueries({ queryKey: ['admin-account-displays', siteId] }) + } catch (error) { + message.error(errorText(error)) + } finally { + setWorking(false) + } + } + + const receivedColumns: TableColumnsType = [ + { + title: '商品', + key: 'listing', + width: 280, + fixed: 'left', + render: (_, row) => ( +
+ {formatText(row.title)} + {formatText(row.listing_no)} + + {formatText(row.game_name)} · {formatText(row.server_region)} ·{' '} + {formatText(row.rank_level)} + +
+ ), + }, + { + title: '号主', + key: 'owner', + width: 160, + render: (_, row) => ( +
+ {formatText(row.owner_nickname)} + {maskPhone(row.owner_phone)} + ID #{row.owner_id} +
+ ), + }, + { + title: '价格', + key: 'price', + width: 180, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.price_cent)} + 卖家 {formatMoney(row.seller_total_price_cent)} + 押金 {formatMoney(row.deposit_amount_cent)} + 哈夫币 {row.haf_coin_amount}M +
+ ), + }, + { + title: '比例', + key: 'ratio', + width: 140, + align: 'right', + render: (_, row) => ( +
+ 卖家 {row.seller_ratio.toFixed(2)} + 买家 {row.buyer_ratio.toFixed(2)} +
+ ), + }, + { + title: '状态', + key: 'status', + width: 150, + render: (_, row) => ( +
+ + 审核 {readStatusMeta(reviewStatusMap, row.review_status).label} + {row.review_reason ? {row.review_reason} : null} +
+ ), + }, + { + title: '时间', + key: 'time', + width: 180, + render: (_, row) => ( +
+ 上架 {formatDateTime(row.published_at)} + 创建 {formatDateTime(row.created_at)} +
+ ), + }, + { + title: '操作', + key: 'actions', + width: 120, + fixed: 'right', + render: (_, row) => + row.status === 'offline' ? ( + + ) : ( + + ), + }, + ] + + const accountColumns: TableColumnsType = [ + { + title: '商品', + key: 'listing', + width: 280, + fixed: 'left', + render: (_, row) => ( +
+ {formatText(row.title)} + {formatText(row.listing_no)} + + {formatText(row.game_name)} · {formatText(row.server_region)} ·{' '} + {formatText(row.rank_level)} + +
+ ), + }, + { + title: '展示', + key: 'display', + width: 140, + render: (_, row) => ( +
+ + 商品 {readStatusMeta(listingStatusMap, row.listing_status).label} + {row.in_transaction ? 交易中 : null} +
+ ), + }, + { + title: '分成比例', + key: 'ratio', + width: 150, + align: 'right', + render: (_, row) => ( +
+ 基础 {Number(row.base_ratio ?? 0).toFixed(2)} + 站点 {Number(row.site_ratio ?? 0).toFixed(2)} + 调整 {Number(row.shelf_ratio_delta ?? 0).toFixed(2)} 点 +
+ ), + }, + { + title: '价格', + key: 'price', + width: 160, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.site_price_cent)} + 基础 {formatMoney(row.price_cent)} + 加价 {formatMoney(row.markup_amount_cent)} + 押金 {formatMoney(row.deposit_amount_cent)} +
+ ), + }, + { + title: '来源', + key: 'source', + width: 130, + render: (_, row) => ( +
+ {formatText(row.source_channel)} + 展示 ID #{row.id} +
+ ), + }, + { + title: '操作', + key: 'actions', + width: 190, + fixed: 'right', + render: (_, row) => ( + + + + + ), + }, + ] + + return ( +
+ + + 收货商品 {receivedQuery.data?.total ?? 0} + + 账号展示 {accountRows.length} + {receivedQuery.isFetching || accountsQuery.isFetching ? ( + 正在更新 + ) : null} + + } + extra={ + + } + /> + + {!canView ? ( + + ) : ( + + )} + + { + setTab(next) + setSelectedAccountIds([]) + }} + items={[ + { + key: 'received', + label: '收货商品', + children: ( + <> + { + setReceivedFilters({ + ...receivedDraft, + keyword: receivedDraft.keyword?.trim() || undefined, + }) + setReceivedPage(1) + }} + onReset={ + Object.values(receivedDraft).some(Boolean) || + Object.values(receivedFilters).some(Boolean) + ? () => { + setReceivedDraft({}) + setReceivedFilters({}) + setReceivedPage(1) + } + : undefined + } + loading={receivedQuery.isFetching} + extra={ + + {receivedQuery.isPending + ? '加载中' + : `当前 ${receivedRows.length} 件`} + + } + > + + + setReceivedDraft(current => ({ + ...current, + keyword: event.target.value, + })) + } + /> + + + + setReceivedDraft(current => ({ + ...current, + review_status: value, + })) + } + /> + + + + {receivedQuery.isError ? ( + void receivedQuery.refetch()}> + 重试 + + } + /> + ) : null} + + + title="站点收货商品" + rowKey="listing_id" + columns={receivedColumns} + dataSource={receivedRows} + loading={receivedQuery.isFetching} + total={receivedQuery.data?.total ?? 0} + page={receivedPage} + pageSize={receivedPageSize} + onPageChange={(nextPage, nextSize) => { + setReceivedPage(nextPage) + setReceivedPageSize(nextSize) + }} + emptyDescription={canView ? '该站点还没有收货商品' : '当前账号没有查看权限'} + /> + + ), + }, + { + key: 'accounts', + label: '账号展示', + children: ( + <> + { + setAccountStatus(accountStatus || undefined) + setAccountPage(1) + }} + onReset={ + accountStatus || accountKeyword + ? () => { + setAccountStatus(undefined) + setAccountKeyword('') + setAccountPage(1) + setSelectedAccountIds([]) + } + : undefined + } + loading={accountsQuery.isFetching} + extra={ + selectedAccountIds.length > 0 ? ( + + + + + + ) : ( + + {accountsQuery.isPending ? '加载中' : `当前 ${filteredAccounts.length} 个账号`} + + ) + } + > + + { + setAccountKeyword(event.target.value) + setAccountPage(1) + }} + /> + + + + setDraftFilters(current => ({ ...current, site_id: value || undefined })) + } + /> + + + setDraftFilters(current => ({ ...current, status: value }))} + /> + + + + setDraftFilters(current => ({ ...current, keyword: event.target.value })) + } + /> + + + + {ordersQuery.isError ? ( + void ordersQuery.refetch()}> + 重试 + + } + /> + ) : null} + + + title="订单快照" + rowKey="order_id" + columns={columns} + dataSource={ordersQuery.data?.items ?? []} + loading={ordersQuery.isFetching} + total={ordersQuery.data?.total ?? 0} + page={page} + pageSize={pageSize} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }} + emptyDescription={canView ? '没有符合条件的分销订单' : '当前账号没有查看权限'} + /> + + setDetailId(undefined)} + title={detailQuery.data?.order_no ?? '分销订单详情'} + subtitle={ + detailQuery.data + ? `${siteName(detailQuery.data.site_id)} · ${ + modeLabels[detailQuery.data.distribution_mode] ?? + detailQuery.data.distribution_mode + }` + : undefined + } + tags={ + detailQuery.data ? ( + + + + 退款 {readStatusMeta(refundStatusMap, detailQuery.data.refund_status).label} + + ) : undefined + } + loading={detailQuery.isPending} + error={detailQuery.isError ? errorText(detailQuery.error) : null} + onRetry={() => void detailQuery.refetch()} + sections={detailQuery.data ? buildSections(detailQuery.data, siteName) : []} + width={820} + /> +
+ ) +} diff --git a/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx b/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx new file mode 100644 index 0000000..8f679c2 --- /dev/null +++ b/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx @@ -0,0 +1,368 @@ +import { useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Form, + InputNumber, + Modal, + Select, + Space, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { DeleteOutlined, LinkOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' + +import { + bindAdminPaymentChannel, + fetchAdminPaymentChannels, + fetchAdminPaymentConfigOptions, + unbindAdminPaymentChannel, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { + DistributionSiteSelect, + useDistributionSiteSelection, +} from '@/features/admin/components/DistributionSiteSelect' +import type { AdminPaymentChannel } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' + +interface BindFormValues { + config_id: number + priority: number +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +const providerLabels: Record = { + leshua: '乐刷', + lakala: '拉卡拉', + shuncheng: '顺诚', + mock: 'Mock', +} + +export function AdminDistributionPaymentChannelsPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [bindOpen, setBindOpen] = useState(false) + const [binding, setBinding] = useState(false) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:payment_view') + ) + const canManage = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:payment_manage') + ) + const canReadConfigs = Boolean( + permissions?.includes('*') || permissions?.includes('payment_config:list') + ) + const selection = useDistributionSiteSelection() + const siteId = selection.siteId + + const channelsQuery = useQuery({ + queryKey: ['admin-payment-channels', siteId], + queryFn: () => fetchAdminPaymentChannels(siteId as number), + enabled: canView && Boolean(siteId), + }) + + const configsQuery = useQuery({ + queryKey: ['admin-payment-config-options'], + queryFn: fetchAdminPaymentConfigOptions, + enabled: canView && canReadConfigs, + staleTime: 60_000, + }) + + const channels = channelsQuery.data ?? [] + const configs = configsQuery.data ?? [] + + const configMap = useMemo( + () => new Map(configs.map(config => [config.id, config])), + [configs] + ) + + const openBind = () => { + form.setFieldsValue({ config_id: undefined as unknown as number, priority: 0 }) + setBindOpen(true) + } + + const bind = async () => { + if (!siteId) return + const values = await form.validateFields() + setBinding(true) + try { + await bindAdminPaymentChannel(siteId, values.config_id, values.priority ?? 0) + message.success('支付通道已绑定') + setBindOpen(false) + await queryClient.invalidateQueries({ queryKey: ['admin-payment-channels', siteId] }) + } catch (error) { + message.error(errorText(error)) + } finally { + setBinding(false) + } + } + + const unbind = (channel: AdminPaymentChannel) => { + modal.confirm({ + title: `确认解绑支付配置 #${channel.config_id}?`, + content: '解绑后该站点不再使用这条支付通道,已支付订单不受影响。', + okText: '解绑', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await unbindAdminPaymentChannel(channel.id) + message.success('支付通道已解绑') + await queryClient.invalidateQueries({ + queryKey: ['admin-payment-channels', siteId], + }) + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const columns: TableColumnsType = [ + { + title: '绑定记录', + dataIndex: 'id', + width: 120, + fixed: 'left', + render: value => #{value}, + }, + { + title: '支付配置', + key: 'config', + width: 320, + render: (_, row) => { + const config = configMap.get(row.config_id) + return ( +
+ {config?.name || `配置 #${row.config_id}`} + + {config ? providerLabels[config.provider] ?? config.provider : '未知服务商'} + {config?.pay_way ? ` · ${config.pay_way}` : ''} + + 配置 ID {row.config_id} +
+ ) + }, + }, + { + title: '环境 / 状态', + key: 'status', + width: 170, + render: (_, row) => { + const config = configMap.get(row.config_id) + return ( +
+ {row.status} + {config ? ( + + {config.environment || '—'} · {config.status} + + ) : null} + {config?.is_default ? 主站默认配置 : null} +
+ ) + }, + }, + { + title: '优先级', + dataIndex: 'priority', + width: 100, + align: 'right', + }, + { + title: '操作', + key: 'actions', + width: 100, + fixed: 'right', + render: (_, row) => ( + + ), + }, + ] + + return ( +
+ + 已绑定 {channels.length} 条 + {selection.site ? {selection.site.site_name} : null} + {channelsQuery.isFetching ? 正在更新 : null} + + } + extra={ + + {canManage ? ( + + ) : null} + + + } + /> + + {!canView ? ( + + ) : ( + + )} + + {canView && !canReadConfigs ? ( + + ) : null} + + {configsQuery.isError ? ( + + ) : null} + + {channelsQuery.isError ? ( + void channelsQuery.refetch()}> + 重试 + + } + /> + ) : null} + + + title="站点支付通道" + rowKey="id" + columns={columns} + dataSource={channels} + loading={channelsQuery.isFetching} + emptyDescription={canView ? '该站点还没有绑定支付通道' : '当前账号没有查看权限'} + extra={ + + 绑定顺序按优先级升序,未绑定时回退主站默认支付配置。 + + } + /> + + (binding ? undefined : setBindOpen(false))} + footer={ + + + + + } + > + + form={form} layout="vertical" disabled={binding}> + {canReadConfigs ? ( + + + setDraftFilters(current => ({ ...current, keyword: event.target.value })) + } + /> + + + setDraftFilters(current => ({ ...current, bound: value }))} + /> + + + setUploadNote(event.target.value)} + /> + +
+ {pendingImages.map(item => ( +
+ 待上传二维码 + + setPendingImages(current => + current.map(row => + row.id === item.id ? { ...row, groupName: event.target.value } : row + ) + ) + } + /> +
+ ))} +
+
+ + (saving ? undefined : setEditing(null))} + maskClosable={!saving} + closable={!saving} + footer={ + + + + + } + > + form={editForm} layout="vertical" disabled={saving}> + + + + + + + + + + ({ + value: site.id, + label: `${site.site_name} · ${site.site_code}`, + })), + ]} + onChange={value => setDraftSiteId(value)} + /> + + + + + + + { + if (form.getFieldValue('scope') === 'site' && !value) { + throw new Error('请选择站点') + } + }, + }, + ]} + > + applyModeDefaults(value)} /> + + + + {mode === 'two_party_platform_receive' ? ( + + + + + + ) : null} + {mode === 'two_party_distributor_receive' ? ( + + + + + + ) : null} + {mode === 'two_party_distributor_receive' ? ( + + + + + + ) : null} + {mode === 'two_party_platform_receive' ? ( + + + + + + ) : null} + {mode === 'three_party' ? ( + <> + + + + + + + + + + + + ) : null} + {mode === 'site_self_sell' ? ( + + + + + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 业务节点模板 + + 留空表示该节点回退主站模板;保存时会整体覆盖本站节点映射。 + + {businessNodes.map(node => ( + + {node.name} + {node.code} + 参考 {node.template} + + } + extra={node.description} + > + + + ))} + + + + {!canManage ? ( + + 需要 distribution:site_manage 权限 + + ) : null} + + + + + + + + + 发送测试短信 + + } + > + + 测试将使用当前表单中的配置(含尚未保存的修改),不会写入站点配置。 + +
void sendTest()}> + + + + +
+ +
+
+ 服务商 + {config?.provider || 'aliyun'} +
+
+ 签名 + {config?.sign_name || '未配置'} +
+
+ 登录模板 + {config?.login_template_code || '未配置'} +
+
+
+ +
+
+ ) +} diff --git a/frontend/src/features/admin/AdminDistributionSettlementsPage.tsx b/frontend/src/features/admin/AdminDistributionSettlementsPage.tsx new file mode 100644 index 0000000..2a9e7df --- /dev/null +++ b/frontend/src/features/admin/AdminDistributionSettlementsPage.tsx @@ -0,0 +1,501 @@ +import { useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + DatePicker, + Form, + Modal, + Space, + Table, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { + CheckCircleOutlined, + PlusOutlined, + ReloadOutlined, + StopOutlined, + UnorderedListOutlined, +} from '@ant-design/icons' +import dayjs, { type Dayjs } from 'dayjs' + +import { + cancelAdminDistributionSettlementBatch, + createAdminDistributionSettlementBatch, + fetchAdminDistributionSettlementBatches, + fetchAdminDistributionSettlementItems, + settleAdminDistributionSettlementBatch, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { + DistributionSiteSelect, + useDistributionSiteSelection, +} from '@/features/admin/components/DistributionSiteSelect' +import { StatusTag } from '@/features/admin/components/StatusTag' +import { readStatusMeta, type StatusMeta } from '@/features/admin/labels' +import type { + AdminDistributionSettlementBatch, + AdminDistributionSettlementItem, +} from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney } from '@/shared/utils/format' + +const batchStatusMap: Record = { + draft: { label: '待入账', tone: 'gold' }, + pending: { label: '待入账', tone: 'gold' }, + settled: { label: '已释放入账', tone: 'success' }, + cancelled: { label: '已作废', tone: 'default' }, +} + +const partyRoleLabels: Record = { + distributor_receive: '收号分成', + distributor_sell: '卖出分成', + distributor_a: '分销 A', + distributor_b: '分销 B', +} + +interface BatchFormValues { + period: [Dayjs, Dayjs] +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +function batchKind(batchNo: string) { + if (batchNo.startsWith('DA')) return { label: '自动对账', color: 'blue' } + if (batchNo.startsWith('DS')) return { label: '人工批次', color: 'gold' } + return { label: '批次', color: 'default' } +} + +export function AdminDistributionSettlementsPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [createOpen, setCreateOpen] = useState(false) + const [creating, setCreating] = useState(false) + const [workingId, setWorkingId] = useState() + const [itemsBatch, setItemsBatch] = useState() + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:settlement_view') + ) + const canManage = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:settlement_manage') + ) + const selection = useDistributionSiteSelection() + const siteId = selection.siteId + + const batchesQuery = useQuery({ + queryKey: ['admin-distribution-settlement-batches', siteId], + queryFn: () => fetchAdminDistributionSettlementBatches(siteId as number), + enabled: canView && Boolean(siteId), + }) + + const itemsQuery = useQuery({ + queryKey: ['admin-distribution-settlement-items', itemsBatch?.id], + queryFn: () => fetchAdminDistributionSettlementItems(itemsBatch?.id as number), + enabled: canView && Boolean(itemsBatch?.id), + }) + + const batches = batchesQuery.data ?? [] + + const summary = useMemo(() => { + const pending = batches.filter( + batch => batch.status !== 'settled' && batch.status !== 'cancelled' + ) + const settled = batches.filter(batch => batch.status === 'settled') + return { + total: batches.length, + pendingCount: pending.length, + pendingAmount: pending.reduce((sum, batch) => sum + batch.total_amount_cent, 0), + settledAmount: settled.reduce((sum, batch) => sum + batch.total_amount_cent, 0), + } + }, [batches]) + + const openCreate = () => { + form.setFieldsValue({ + period: [dayjs().subtract(30, 'day').startOf('day'), dayjs()], + }) + setCreateOpen(true) + } + + const createBatch = async () => { + if (!siteId) return + const values = await form.validateFields() + const [start, end] = values.period + if (!start || !end || !end.isAfter(start)) { + message.error('结算周期结束时间必须晚于开始时间') + return + } + setCreating(true) + try { + await createAdminDistributionSettlementBatch({ + site_id: siteId, + period_start: start.toISOString(), + period_end: end.toISOString(), + }) + message.success('人工结算批次已生成,可核对明细后释放入账') + setCreateOpen(false) + await queryClient.invalidateQueries({ + queryKey: ['admin-distribution-settlement-batches', siteId], + }) + } catch (error) { + message.error(errorText(error)) + } finally { + setCreating(false) + } + } + + const settleBatch = (batch: AdminDistributionSettlementBatch) => { + modal.confirm({ + title: `确认释放批次 ${batch.batch_no}?`, + content: ( +
+ 入账金额:{formatMoney(batch.total_amount_cent)} + 释放后会立即转入站点可提现余额,人工批次不走冷却期。 +
+ ), + okText: '确认入账', + cancelText: '取消', + onOk: async () => { + setWorkingId(batch.id) + try { + await settleAdminDistributionSettlementBatch(batch.id) + message.success('批次已释放到站点可提现余额') + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['admin-distribution-settlement-batches', siteId], + }), + queryClient.invalidateQueries({ + queryKey: ['admin-distribution-settlement-items', batch.id], + }), + queryClient.invalidateQueries({ + queryKey: ['admin-distribution-wallet', siteId], + }), + ]) + } catch (error) { + message.error(errorText(error)) + } finally { + setWorkingId(undefined) + } + }, + }) + } + + const cancelBatch = (batch: AdminDistributionSettlementBatch) => { + modal.confirm({ + title: `确认作废批次 ${batch.batch_no}?`, + content: '作废后批次内订单可重新进入自动释放或新的人工批次。', + okText: '确认作废', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + setWorkingId(batch.id) + try { + await cancelAdminDistributionSettlementBatch(batch.id) + message.success('批次已作废') + await queryClient.invalidateQueries({ + queryKey: ['admin-distribution-settlement-batches', siteId], + }) + } catch (error) { + message.error(errorText(error)) + } finally { + setWorkingId(undefined) + } + }, + }) + } + + const columns: TableColumnsType = [ + { + title: '批次号', + key: 'batch', + width: 260, + fixed: 'left', + render: (_, row) => ( +
+ {row.batch_no} + {batchKind(row.batch_no).label} +
+ ), + }, + { + title: '对账周期', + key: 'period', + width: 250, + render: (_, row) => ( + + {formatDateTime(row.period_start)} ~ {formatDateTime(row.period_end)} + + ), + }, + { + title: '入账金额', + dataIndex: 'total_amount_cent', + width: 150, + align: 'right', + render: value => {formatMoney(value)}, + }, + { + title: '状态', + dataIndex: 'status', + width: 120, + render: value => , + }, + { + title: '操作', + key: 'actions', + width: 260, + fixed: 'right', + render: (_, row) => { + const closed = row.status === 'settled' || row.status === 'cancelled' + return ( + + + {!closed ? ( + + ) : null} + {!closed ? ( + + ) : null} + + ) + }, + }, + ] + + const itemColumns: TableColumnsType = [ + { + title: '订单 ID', + dataIndex: 'order_id', + width: 120, + render: value => #{value}, + }, + { + title: '分成角色', + dataIndex: 'party_role', + width: 150, + render: value => partyRoleLabels[value] ?? value, + }, + { + title: '入账金额', + dataIndex: 'amount_cent', + width: 140, + align: 'right', + render: value => formatMoney(value), + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => + value === 'settled' ? ( + 已入账 + ) : ( + 待入账 + ), + }, + ] + + const refresh = () => { + void batchesQuery.refetch() + void selection.refetch() + } + + return ( +
+ + 共 {summary.total} 个批次 + 待入账 {summary.pendingCount} + 待入账金额 {formatMoney(summary.pendingAmount)} + 已入账 {formatMoney(summary.settledAmount)} + + } + extra={ + + {canManage ? ( + + ) : null} + + + } + /> + + {!canView ? ( + + ) : ( + + )} + + {canView && siteId ? ( + + ) : null} + + {batchesQuery.isError ? ( + + 重试 + + } + /> + ) : null} + + + title="站点结算批次" + rowKey="id" + columns={columns} + dataSource={batches} + loading={batchesQuery.isFetching} + emptyDescription={canView ? '该站点还没有结算批次' : '当前账号没有查看权限'} + extra={ + + {selection.site ? `${selection.site.site_name} · ${selection.site.site_code}` : ''} + + } + /> + + (creating ? undefined : setCreateOpen(false))} + footer={ + + + + + } + > + + form={form} layout="vertical" disabled={creating}> + + + + + 周期采用左闭右开:开始时间 ≤ 财务快照创建时间 < 结束时间。 + + + + + setItemsBatch(undefined)} + footer={ + + } + > + + + {itemsBatch ? batchKind(itemsBatch.batch_no).label : '批次'} + + {itemsBatch ? ( + + ) : null} + + 合计 {formatMoney(itemsBatch?.total_amount_cent ?? 0)} + + + + rowKey="id" + size="small" + columns={itemColumns} + dataSource={itemsQuery.data ?? []} + loading={itemsQuery.isFetching} + pagination={false} + scroll={{ y: 360 }} + locale={{ emptyText: '该批次没有明细' }} + /> + +
+ ) +} diff --git a/frontend/src/features/admin/AdminDistributionUsersPage.tsx b/frontend/src/features/admin/AdminDistributionUsersPage.tsx new file mode 100644 index 0000000..93617c3 --- /dev/null +++ b/frontend/src/features/admin/AdminDistributionUsersPage.tsx @@ -0,0 +1,376 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { + Alert, + Avatar, + Button, + Input, + Select, + Space, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { ReloadOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons' + +import { fetchAdminDistributionUsers } from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { + DetailDrawer, + type DetailSection, +} from '@/features/admin/components/DetailDrawer' +import { + DistributionSiteSelect, + useDistributionSiteSelection, +} from '@/features/admin/components/DistributionSiteSelect' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import { StatusTag } from '@/features/admin/components/StatusTag' +import { + readStatusMeta, + realnameStatusMap, + userStatusMap, +} from '@/features/admin/labels' +import type { + AdminDistributionUser, + AdminDistributionUserQuery, +} from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { + formatDateTime, + formatMoney, + formatText, + maskPhone, +} from '@/shared/utils/format' + +const sourceLabels: Record = { + register: '本站注册', + order: '本站下单', + both: '注册并下单', +} + +const sourceOptions = [ + { value: 'all', label: '全部客户' }, + { value: 'register', label: '本站注册' }, + { value: 'order', label: '本站下单' }, +] + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +function buildSections(user: AdminDistributionUser): DetailSection[] { + return [ + { + key: 'profile', + title: '客户资料', + icon: , + fields: [ + { key: 'id', label: '用户 ID', value: #{user.id} }, + { key: 'phone', label: '手机号', value: {formatText(user.phone)} }, + { key: 'nickname', label: '昵称', value: formatText(user.nickname) }, + { + key: 'source', + label: '客户来源', + value: {sourceLabels[user.source] ?? formatText(user.source)}, + }, + { + key: 'realname', + label: '实名状态', + value: , + }, + { + key: 'status', + label: '账号状态', + value: , + }, + { key: 'created', label: '注册时间', value: formatDateTime(user.created_at) }, + { key: 'login', label: '最近登录', value: formatDateTime(user.last_login_at) }, + ], + }, + { + key: 'orders', + title: '交易贡献', + icon: , + fields: [ + { key: 'order_count', label: '有效订单', value: `${user.order_count} 单` }, + { + key: 'spent', + label: '累计消费', + value: {formatMoney(user.total_spent_cent)}, + }, + { key: 'last_order', label: '最近下单', value: formatDateTime(user.last_order_at) }, + ], + }, + ] +} + +export function AdminDistributionUsersPage() { + const [draftFilters, setDraftFilters] = useState({}) + const [filters, setFilters] = useState({}) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [selected, setSelected] = useState() + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:site_view') + ) + const selection = useDistributionSiteSelection() + const siteId = selection.siteId + + const usersQuery = useQuery({ + queryKey: ['admin-distribution-users', siteId, filters, page, pageSize], + queryFn: () => + fetchAdminDistributionUsers(siteId as number, { + ...filters, + page, + page_size: pageSize, + }), + enabled: canView && Boolean(siteId), + placeholderData: keepPreviousData, + }) + + const data = usersQuery.data + const rows = data?.items ?? [] + + const columns: TableColumnsType = [ + { + title: '客户', + key: 'customer', + width: 240, + fixed: 'left', + render: (_, row) => ( +
+ } + className="admin-user-cell__avatar" + /> +
+ {formatText(row.nickname)} + {maskPhone(row.phone)} + ID #{row.id} +
+
+ ), + }, + { + title: '来源', + dataIndex: 'source', + width: 130, + render: value => ( + {sourceLabels[value] ?? formatText(value)} + ), + }, + { + title: '实名 / 状态', + key: 'status', + width: 150, + render: (_, row) => ( +
+ + +
+ ), + }, + { + title: '有效订单', + dataIndex: 'order_count', + width: 110, + align: 'right', + render: value => `${value} 单`, + }, + { + title: '累计消费', + dataIndex: 'total_spent_cent', + width: 140, + align: 'right', + render: value => ( + {formatMoney(value)} + ), + }, + { + title: '最近下单', + dataIndex: 'last_order_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '注册时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '最近登录', + dataIndex: 'last_login_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 90, + fixed: 'right', + render: (_, row) => ( + + ), + }, + ] + + const summary = useMemo( + () => ({ + totalUsers: data?.total_users ?? 0, + orderUsers: data?.order_users ?? 0, + filtered: data?.total ?? 0, + }), + [data] + ) + + const applyFilters = () => { + setFilters({ + keyword: draftFilters.keyword?.trim() || undefined, + source: draftFilters.source === 'all' ? undefined : draftFilters.source, + }) + setPage(1) + } + + const resetFilters = () => { + setDraftFilters({}) + setFilters({}) + setPage(1) + } + + return ( +
+ + 本站注册 {summary.totalUsers} + 成交客户 {summary.orderUsers} + 筛选结果 {summary.filtered} + {usersQuery.isFetching ? 正在更新 : null} + + } + extra={ + + } + /> + + {!canView ? ( + + ) : ( + + )} + + + {usersQuery.isPending ? '加载中' : `当前 ${rows.length} 位客户`} + + } + > + + + setDraftFilters(current => ({ ...current, keyword: event.target.value })) + } + /> + + + + + + + + + + + {accountType === 'bank' ? ( + <> + + + + + + + + ) : null} + + {!canManage ? ( + + 当前账号只有查看权限,需要 distribution:settlement_manage 才能提交申请。 + + ) : null} + + + + + + + title={ + + + 提现记录 + + } + extra={ + + {withdrawalsQuery.isPending ? '加载中' : `共 ${withdrawals?.total ?? 0} 笔`} + + } + rowKey="id" + columns={withdrawalColumns} + dataSource={withdrawals?.items ?? []} + loading={withdrawalsQuery.isFetching} + total={withdrawals?.total ?? 0} + page={page} + pageSize={pageSize} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }} + emptyDescription={canView ? '该站点还没有提现记录' : '当前账号没有查看权限'} + /> + + +
+ ) +} diff --git a/frontend/src/features/admin/AdminSiteAnnouncementsPage.tsx b/frontend/src/features/admin/AdminSiteAnnouncementsPage.tsx new file mode 100644 index 0000000..38833a5 --- /dev/null +++ b/frontend/src/features/admin/AdminSiteAnnouncementsPage.tsx @@ -0,0 +1,557 @@ +import { useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Col, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + Space, + Switch, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { + DeleteOutlined, + EditOutlined, + InboxOutlined, + PlusOutlined, + ReloadOutlined, + SendOutlined, + StopOutlined, +} from '@ant-design/icons' + +import { + archiveAdminSiteAnnouncement, + createAdminSiteAnnouncement, + deleteAdminSiteAnnouncement, + fetchAdminSiteAnnouncements, + publishAdminSiteAnnouncement, + unarchiveAdminSiteAnnouncement, + updateAdminSiteAnnouncement, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { + DetailDrawer, + type DetailSection, +} from '@/features/admin/components/DetailDrawer' +import { + DistributionSiteSelect, + useDistributionSiteSelection, +} from '@/features/admin/components/DistributionSiteSelect' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import { StatusTag } from '@/features/admin/components/StatusTag' +import { + announcementCategoryMap, + announcementCategoryOptions, + announcementStatusMap, + announcementStatusOptions, + readStatusMeta, +} from '@/features/admin/labels' +import type { + AdminSiteAnnouncement, + AdminSiteAnnouncementPayload, +} from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatText } from '@/shared/utils/format' + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +function buildSections(item: AdminSiteAnnouncement): DetailSection[] { + return [ + { + key: 'content', + title: '公告内容', + icon: , + fields: [ + { + key: 'flags', + label: '标记', + value: ( + + {item.is_pinned ? 置顶 : null} + {item.is_important ? 重要 : null} + {item.is_popup ? 弹窗 : null} + {!item.is_pinned && !item.is_important && !item.is_popup ? 普通公告 : null} + + ), + }, + { + key: 'category', + label: '分类', + value: announcementCategoryMap[item.category] ?? formatText(item.category), + }, + { key: 'priority', label: '排序权重', value: item.priority }, + { key: 'views', label: '浏览量', value: item.view_count }, + { key: 'published', label: '发布时间', value: formatDateTime(item.published_at) }, + { key: 'updated', label: '更新时间', value: formatDateTime(item.updated_at) }, + { + key: 'body', + label: '正文', + span: 2, + value:
{item.content}
, + }, + ], + }, + ] +} + +export function AdminSiteAnnouncementsPage() { + const { message, modal } = App.useApp() + const [form] = Form.useForm() + const [status, setStatus] = useState() + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [editorOpen, setEditorOpen] = useState(false) + const [editing, setEditing] = useState() + const [submitting, setSubmitting] = useState(false) + const [detail, setDetail] = useState() + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:announcement_view') + ) + const canManage = Boolean( + permissions?.includes('*') || permissions?.includes('distribution:announcement_manage') + ) + const selection = useDistributionSiteSelection() + const siteId = selection.siteId + + const listQuery = useQuery({ + queryKey: ['admin-site-announcements', siteId, status, page, pageSize], + queryFn: () => + fetchAdminSiteAnnouncements(siteId as number, { + status, + page, + page_size: pageSize, + }), + enabled: canView && Boolean(siteId), + placeholderData: keepPreviousData, + }) + + const data = listQuery.data + const rows = data?.items ?? [] + + const openCreate = () => { + setEditing(undefined) + form.setFieldsValue({ + title: '', + content: '', + category: 'notice', + priority: 0, + is_pinned: false, + is_important: false, + is_popup: false, + }) + setEditorOpen(true) + } + + const openEdit = (item: AdminSiteAnnouncement) => { + setEditing(item) + form.setFieldsValue({ + title: item.title, + content: item.content, + category: item.category, + priority: item.priority, + is_pinned: item.is_pinned, + is_important: item.is_important, + is_popup: item.is_popup, + }) + setEditorOpen(true) + } + + const save = async () => { + if (!siteId) return + const values = await form.validateFields() + setSubmitting(true) + try { + if (editing) { + await updateAdminSiteAnnouncement(siteId, editing.id, values) + message.success('站点公告已更新') + } else { + await createAdminSiteAnnouncement(siteId, values) + message.success('站点公告已创建为草稿') + } + setEditorOpen(false) + await listQuery.refetch() + } catch (error) { + message.error(errorText(error)) + } finally { + setSubmitting(false) + } + } + + const changeStatus = (item: AdminSiteAnnouncement, action: 'publish' | 'archive' | 'unarchive') => { + if (!siteId) return + const label = action === 'publish' ? '发布' : action === 'archive' ? '归档' : '取消归档' + modal.confirm({ + title: `确认${label}公告「${item.title}」?`, + content: + action === 'publish' + ? '发布后该公告会在本站前台展示;弹窗公告会按每日一次规则弹出。' + : action === 'archive' + ? '归档后公告不再在前台展示,但内容会保留。' + : '取消归档后公告会重新发布到前台。', + okText: `确认${label}`, + cancelText: '取消', + onOk: async () => { + try { + if (action === 'publish') await publishAdminSiteAnnouncement(siteId, item.id) + if (action === 'archive') await archiveAdminSiteAnnouncement(siteId, item.id) + if (action === 'unarchive') await unarchiveAdminSiteAnnouncement(siteId, item.id) + message.success(`公告已${label}`) + await listQuery.refetch() + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const remove = (item: AdminSiteAnnouncement) => { + if (!siteId) return + modal.confirm({ + title: `确认删除公告「${item.title}」?`, + content: '删除后无法恢复;如果只是暂时下线,建议使用归档。', + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminSiteAnnouncement(siteId, item.id) + message.success('站点公告已删除') + await listQuery.refetch() + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const columns: TableColumnsType = [ + { + title: '公告', + key: 'title', + width: 300, + fixed: 'left', + render: (_, row) => ( +
+ + {row.content.slice(0, 80)}{row.content.length > 80 ? '…' : ''} + + {announcementCategoryMap[row.category] ?? row.category} + {row.is_pinned ? 置顶 : null} + {row.is_important ? 重要 : null} + {row.is_popup ? 弹窗 : null} + +
+ ), + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => , + }, + { + title: '排序', + dataIndex: 'priority', + width: 90, + align: 'right', + }, + { + title: '浏览', + dataIndex: 'view_count', + width: 90, + align: 'right', + }, + { + title: '发布时间', + dataIndex: 'published_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 250, + fixed: 'right', + render: (_, row) => ( + + {row.status === 'draft' || row.status === 'archived' ? ( + + ) : null} + {row.status === 'published' ? ( + + ) : null} + + + + ), + }, + ] + + return ( +
+ + 共 {data?.total ?? 0} 条 + {selection.site ? {selection.site.site_name} : null} + {listQuery.isFetching ? 正在更新 : null} + + } + extra={ + + {canManage ? ( + + ) : null} + + + } + /> + + {!canView ? ( + + ) : ( + + )} + + setPage(1)} + onReset={ + status + ? () => { + setStatus(undefined) + setPage(1) + } + : undefined + } + loading={listQuery.isFetching} + extra={ + + {listQuery.isPending ? '加载中' : `当前 ${rows.length} 条`} + + } + > + + + + + + + setSiteId(value)} + options={sites.map(item => ({ + value: item.id, + label: `${item.site_name} · ${item.site_code}`, + }))} + /> + {site ? ( + + {site.status === 'active' ? '运营中' : '已停用'} + + ) : null} + {site?.domain ? {site.domain} : null} + +
+ ) +} diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index fab321a..6437e88 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -797,3 +797,462 @@ export interface OperatingExpensePayload { remark: string voucher_url?: string } + +/** 分站钱包余额快照,金额单位全部为分。 */ +export interface AdminDistributionWallet { + site_id: number + available_balance_cent: number + frozen_balance_cent: number + pending_release_cent: number + total_settled_cent: number + total_withdrawn_cent: number + settlement_hold_days: number +} + +export type AdminDistributionWithdrawalStatus = + | 'pending' + | 'processing' + | 'completed' + | 'rejected' + | 'cancelled' + +export interface AdminDistributionWithdrawal { + id: number + withdraw_no: string + site_id: number + amount_cent: number + fee_cent: number + actual_amount_cent: number + account_type: string + account_name: string + account_no: string + bank_name: string + bank_branch: string + status: string + review_remark: string + payment_proof_url: string + payment_remark: string + paid_at?: string | null + created_at: string +} + +export interface AdminDistributionWithdrawalPage { + items: AdminDistributionWithdrawal[] + total: number + page: number + page_size: number +} + +export interface ApplyAdminDistributionWithdrawalPayload { + amount_cent: number + account_type: 'alipay' | 'bank' + account_name: string + account_no: string + bank_name?: string + bank_branch?: string +} + +export interface AdminDistributionSMSConfig { + enabled: boolean + provider: string + access_key_id: string + masked_secret: string + has_secret: boolean + endpoint: string + sign_name: string + login_template_code: string + business_templates: Record +} + +export interface UpdateAdminDistributionSMSConfigPayload { + enabled?: boolean + provider?: string + access_key_id?: string + access_key_secret?: string + endpoint?: string + sign_name?: string + login_template_code?: string + business_templates?: Record +} + +export interface TestAdminDistributionSMSConfigPayload { + phone: string + provider?: string + access_key_id?: string + access_key_secret?: string + endpoint?: string + sign_name?: string + login_template_code?: string +} + +/** 邀请返利比例沿用后端 0-1 小数口径,展示时再转百分比。 */ +export interface AdminDistributionInvitationConfig { + distribution_site_id: number + first_month_rate: number + second_month_rate: number + subsequent_rate: number + enabled: boolean + updated_at: string +} + +export interface UpdateAdminDistributionInvitationConfigPayload { + first_month_rate: number + second_month_rate: number + subsequent_rate: number + enabled?: boolean +} + +export interface AdminDistributionQrCode { + id: number + image_url: string + group_name: string + distribution_site_id?: number | null + status: string + conversation_id?: number | null + used_at?: string | null + expires_at?: string | null + created_by: number + note: string + wecom_renamed: boolean + created_at: string + updated_at: string + bound_conversation_title?: string +} + +export interface AdminDistributionQrCodePage { + items: AdminDistributionQrCode[] + total: number + page: number + page_size: number +} + +export interface AdminDistributionQrCodeStats { + unused_count: number + used_count: number + disabled_count: number + total_count: number +} + +export interface AdminDistributionQrCodeQuery { + page?: number + limit?: number + status?: string + keyword?: string + bound?: string + expire_status?: string + wecom_renamed?: string + created_start?: string + created_end?: string +} + +export interface CreateAdminDistributionQrCodeItem { + image_url: string + group_name?: string + note?: string + expires_at?: string +} + +export interface UpdateAdminDistributionQrCodePayload { + image_url?: string + group_name?: string + note?: string + status?: string + wecom_renamed?: boolean + expires_at?: string + clear_expires_at?: boolean +} + +export interface AdminDistributionRule { + id: number + site_id?: number | null + mode: string + version: number + status: string + platform_receive_ratio: number + platform_sell_ratio: number + distributor_receive_ratio: number + distributor_sell_ratio: number + distributor_a_ratio: number + distributor_b_ratio: number + service_fee_value: number + service_fee_unit: string + payment_fee_bps: number + tax_enabled: boolean + tax_rate_bps: number + effective_from: string + effective_to?: string | null + created_by?: number | null + created_at: string +} + +export interface AdminDistributionRuleQuery { + site_id?: number + mode?: string +} + +export interface CreateAdminDistributionRulePayload { + site_id?: number | null + mode: string + platform_receive_ratio: number + platform_sell_ratio: number + distributor_receive_ratio: number + distributor_sell_ratio: number + distributor_a_ratio: number + distributor_b_ratio: number + service_fee_value: number + service_fee_unit: string + payment_fee_bps: number + tax_enabled: boolean + tax_rate_bps: number + effective_from?: string +} + +/** 分销订单列表/详情快照,金额全部为分。 */ +export interface AdminDistributionOrder { + order_id: number + order_no: string + site_id: number + listing_id: number + account_id: number + owner_id: number + renter_id: number + status: string + handoff_status: string + refund_status: string + settlement_status: string + distributor_settlement_status: string + release_at?: string | null + distribution_mode: string + gross_paid_amount_cent: number + refund_amount_cent: number + net_paid_amount_cent: number + payment_fee_cent: number + service_fee_cent: number + markup_amount_cent: number + tax_amount_cent: number + split_base_amount_cent: number + rounding_adjustment_cent: number + created_at: string + listing_no?: string + title?: string + game_name?: string + server_region?: string + login_platform?: string + rent_duration_hours?: number + rented_at?: string | null + renter_phone?: string + renter_nickname?: string + owner_rent_amount_cent?: number + base_price_cent?: number + shelf_markup_cent?: number + deposit_amount_cent?: number + distributor_earning_cent: number + distributor_role?: string + distributor_markup_cent: number + distributor_split_cent: number +} + +export interface AdminDistributionOrderQuery { + site_id?: number + kind?: string + role?: string + status?: string + mode?: string + financial_status?: string + keyword?: string + page?: number + page_size?: number +} + +export interface AdminDistributionOrderPage { + items: AdminDistributionOrder[] + total: number + page: number + page_size: number +} + +export interface AdminDistributionSettlementBatch { + id: number + site_id: number + batch_no: string + period_start: string + period_end: string + total_amount_cent: number + status: string +} + +export interface AdminDistributionSettlementItem { + id: number + order_id: number + party_role: string + amount_cent: number + status: string +} + +export interface CreateAdminDistributionSettlementBatchPayload { + site_id: number + period_start: string + period_end: string +} + +/** 分销站点客户:register=本站注册,order=本站下单,both=注册并下单。 */ +export interface AdminDistributionUser { + id: number + phone: string + nickname: string + avatar_url: string + realname_status: string + status: string + source: string + order_count: number + total_spent_cent: number + last_order_at?: string | null + created_at: string + last_login_at?: string | null +} + +export interface AdminDistributionUserPage { + items: AdminDistributionUser[] + total: number + page: number + page_size: number + total_users: number + order_users: number +} + +export interface AdminDistributionUserQuery { + keyword?: string + source?: string + page?: number + page_size?: number +} + +/** 分站公告:站长自助维护,与主站公告隔离。 */ +export interface AdminSiteAnnouncement { + id: number + distribution_site_id: number + title: string + content: string + category: string + priority: number + is_pinned: boolean + is_important: boolean + is_popup: boolean + view_count: number + status: string + published_at?: string | null + created_at: string + updated_at: string +} + +export interface AdminSiteAnnouncementPage { + items: AdminSiteAnnouncement[] + total: number + page: number + page_size: number +} + +export interface AdminSiteAnnouncementQuery { + status?: string + page?: number + page_size?: number +} + +export interface AdminSiteAnnouncementPayload { + title: string + content: string + category: string + priority: number + is_pinned: boolean + is_important: boolean + is_popup: boolean +} + +/** 分销站点收到的账号资产(商品级)。 */ +export interface AdminReceivedListing { + listing_id: number + listing_no: string + site_id: number + account_id: number + owner_id: number + owner_phone: string + owner_nickname: string + title: string + game_name: string + server_region: string + login_platform: string + rank_level: string + haf_coin_amount: number + seller_ratio: number + seller_total_price_cent: number + buyer_ratio: number + price_cent: number + deposit_amount_cent: number + status: string + review_status: string + review_reason: string + published_at?: string | null + created_at: string +} + +export interface AdminReceivedListingPage { + items: AdminReceivedListing[] + total: number + page: number + page_size: number +} + +export interface AdminReceivedListingQuery { + status?: string + review_status?: string + game_name?: string + keyword?: string + page?: number + page_size?: number +} + +/** 站点账号展示配置:控制分站是否展示、加价与展示价。 */ +export interface AdminAccountDisplay { + id: number + site_id: number + account_id: number + listing_id: number + display_status: string + markup_amount_cent: number + shelf_ratio_delta: number + base_ratio?: number + site_ratio?: number + listing_no?: string + title?: string + game_name?: string + server_region?: string + login_platform?: string + rank_level?: string + haf_coin_amount?: number + price_cent?: number + site_price_cent?: number + deposit_amount_cent?: number + listing_status?: string + in_transaction?: boolean + listing_site_id?: number | null + source_channel?: string +} + +export interface AdminPaymentChannel { + id: number + site_id: number + config_id: number + priority: number + status: string +} + +/** 可绑定的主站支付配置(密钥字段后端默认不返回)。 */ +export interface AdminPaymentConfigOption { + id: number + name: string + provider: string + pay_way: string + status: string + environment: string + is_default: boolean +} diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index 13c9a4c..79a6847 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -99,6 +99,29 @@ const allNavGroups: NavGroup[] = [ icon: , children: [ { key: '/distribution/sites', label: '站点管理', permission: 'distribution:site_view' }, + { key: '/distribution/orders', label: '分销订单', permission: 'distribution:order_view' }, + { + key: '/distribution/settlements', + label: '结算批次', + permission: 'distribution:settlement_view', + }, + { key: '/distribution/users', label: '分销用户', permission: 'distribution:site_view' }, + { + key: '/distribution/announcements', + label: '站点公告', + permission: 'distribution:announcement_view', + }, + { key: '/distribution/listings', label: '分销商品', permission: 'distribution:account_view' }, + { + key: '/distribution/payment-channels', + label: '支付通道', + permission: 'distribution:payment_view', + }, + { key: '/distribution/wallet', label: '站点钱包', permission: 'distribution:settlement_view' }, + { key: '/distribution/rules', label: '规则版本', permission: 'distribution:rule_view' }, + { key: '/distribution/qrcodes', label: '二维码池', permission: 'qrcode:view' }, + { key: '/distribution/sms', label: '站点短信', permission: 'distribution:site_view' }, + { key: '/distribution/invitation', label: '邀请返利', permission: 'distribution:site_view' }, ], }, { @@ -130,6 +153,17 @@ const sectionMeta: Record = { '/finance/operating-expenses': { title: '运营开支', description: '登记与核对日常运营支出。' }, '/withdrawals': { title: '提现审核', description: '审核提现申请与打款确认。' }, '/distribution/sites': { title: '分销站点', description: '管理分站、规则、结算与站点钱包。' }, + '/distribution/orders': { title: '分销订单', description: '核对分销订单快照与冷却期释放状态。' }, + '/distribution/settlements': { title: '结算批次', description: '生成人工批次并核对、释放站内份额。' }, + '/distribution/users': { title: '分销用户', description: '查看站点注册客户、下单客户与消费贡献。' }, + '/distribution/announcements': { title: '站点公告', description: '维护分站自己的公告中心与弹窗公告。' }, + '/distribution/listings': { title: '分销商品', description: '查看收货商品并控制账号展示与加价。' }, + '/distribution/payment-channels': { title: '支付通道', description: '为分站绑定主站支付商户配置。' }, + '/distribution/wallet': { title: '站点钱包', description: '查看分站余额、结算进度与提现记录。' }, + '/distribution/rules': { title: '规则版本', description: '维护全局与站点级分账规则版本。' }, + '/distribution/qrcodes': { title: '二维码池', description: '按站点维护发布群二维码池与发放状态。' }, + '/distribution/sms': { title: '站点短信', description: '为分站配置独立短信签名、模板与密钥。' }, + '/distribution/invitation': { title: '邀请返利', description: '配置分站邀请好友的返利比例。' }, '/announcements': { title: '公告管理', description: '维护主站与分站公告。' }, '/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' }, '/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' }, diff --git a/frontend/src/shared/api/files.ts b/frontend/src/shared/api/files.ts index d622ed5..c5c1b96 100644 --- a/frontend/src/shared/api/files.ts +++ b/frontend/src/shared/api/files.ts @@ -1,4 +1,5 @@ import { apiClient } from './client' +import { adminClient } from './adminClient' import type { ApiResponse } from './types' import { optimizeImageForUpload } from '@/shared/utils/imageUpload' @@ -23,6 +24,25 @@ export async function uploadFile(file: File, scene: string) { return data.data } +/** 后台文件上传走 HttpOnly Cookie,不能复用用户端 Bearer 客户端。 */ +export async function uploadAdminFile( + file: File, + scene: string, + options: { optimize?: boolean } = {} +) { + const uploadTarget = + options.optimize === false ? file : await optimizeImageForUpload(file, scene) + const form = new FormData() + form.append('file', uploadTarget) + form.append('scene', scene) + const { data } = await adminClient.post>( + '/admin/files/upload', + form, + { headers: { 'Content-Type': 'multipart/form-data' } } + ) + return data.data +} + export function isImageFileURL(url: string) { return ( /\.(png|jpe?g|webp|gif|bmp)(\?.*)?$/i.test(url) || diff --git a/frontend/src/styles/admin-distribution.css b/frontend/src/styles/admin-distribution.css new file mode 100644 index 0000000..94ad7a0 --- /dev/null +++ b/frontend/src/styles/admin-distribution.css @@ -0,0 +1,235 @@ +/* 分销长尾页:站点钱包、短信、邀请、二维码池、规则版本。 */ + +.admin-site-picker { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + padding: 12px 16px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--paper-warm); +} + +.admin-site-picker__select { + min-width: 280px; +} + +.admin-panel-card { + height: 100%; + border-color: var(--line); + background: var(--paper-warm); +} + +.admin-panel-card .ant-card-head { + border-color: var(--line); +} + +.admin-form-hint { + margin: 12px 0 0 !important; + font-size: 12px; +} + +.admin-form-actions { + margin-top: 8px; +} + +.admin-kv-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.admin-kv-list > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding-bottom: 8px; + border-bottom: 1px dashed var(--line); +} + +.admin-kv-list > div:last-child { + padding-bottom: 0; + border-bottom: none; +} + +.admin-kv-list span { + color: var(--ink-muted); + font-size: 12px; +} + +.admin-kv-list strong { + color: var(--ink); + font-weight: 500; + text-align: right; +} + +/* 钱包 */ +.admin-wallet-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin-bottom: 16px; +} + +.admin-wallet-card { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 112px; + border: 1px solid var(--line); + background: var(--paper-warm); +} + +.admin-wallet-card--accent { + border-color: var(--brand-ink); + background: var(--brand-ink); +} + +.admin-wallet-card__label { + color: var(--ink-muted); + font-size: 12px; + letter-spacing: 0.04em; +} + +.admin-wallet-card__value { + color: var(--brand-ink); + font-family: 'Inter', 'DIN Alternate', sans-serif; + font-size: 24px; + font-weight: 600; + line-height: 1.2; +} + +.admin-wallet-card__hint { + color: var(--ink-muted); + font-size: 11px; +} + +.admin-wallet-card--accent .admin-wallet-card__label, +.admin-wallet-card--accent .admin-wallet-card__hint { + color: #b9cbc3; +} + +.admin-wallet-card--accent .admin-wallet-card__value { + color: #f0f4f2; +} + +.admin-wallet-body { + align-items: stretch; +} + +/* 邀请返利 */ +.admin-invite-preview { + display: grid; + gap: 10px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 18px; + padding: 14px; + border: 1px dashed var(--line); + border-radius: 4px; + background: #f8f4ea; +} + +.admin-invite-preview > div { + display: flex; + flex-direction: column; + gap: 4px; +} + +.admin-invite-preview span { + color: var(--ink-muted); + font-size: 12px; +} + +.admin-invite-preview strong { + color: var(--brand-ink); + font-family: 'Inter', 'DIN Alternate', sans-serif; + font-size: 20px; +} + +/* 分销用户 */ +.admin-user-cell { + display: flex; + align-items: center; + gap: 10px; +} + +.admin-user-cell__avatar { + flex: 0 0 auto; + background: #eef2ee; + color: var(--brand-ink); +} + +/* 结算批次 */ +.admin-settlement-detail-head { + margin-bottom: 14px; +} + +/* 二维码池 */ +.admin-qr-thumb { + border: 1px solid var(--line); + border-radius: 3px; + object-fit: cover; +} + +.admin-qr-uploader { + margin-bottom: 14px; +} + +.admin-qr-note { + margin-bottom: 12px; +} + +.admin-qr-pending { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 320px; + overflow-y: auto; +} + +.admin-qr-pending__item { + display: grid; + align-items: center; + gap: 10px; + grid-template-columns: 54px minmax(0, 1fr) auto; + padding: 8px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--paper-warm); +} + +.admin-qr-pending__item img { + width: 54px; + height: 54px; + border-radius: 3px; + object-fit: cover; +} + +@media (max-width: 1200px) { + .admin-wallet-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .admin-site-picker { + align-items: flex-start; + flex-direction: column; + } + + .admin-site-picker__select { + width: 100%; + min-width: 0; + } + + .admin-wallet-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-invite-preview { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 2e753bd..734a74f 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -8,6 +8,7 @@ @import './admin-misc.css'; @import './admin-login.css'; @import './admin-shell.css'; +@import './admin-distribution.css'; @import './mobile-base.css'; @import './mobile-listing.css'; @import './mobile-profile-orders.css';