Compare commits
146 Commits
f6c77e60b6
..
v3.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 309b180087 | |||
| 118d57d710 | |||
| 0cb2b2cebb | |||
| daeb24bbeb | |||
| f4766d32ff | |||
| b920c894b9 | |||
| 37038a7591 | |||
| 944306e45c | |||
| 9ed0c24d11 | |||
| 50349dd8e9 | |||
| e2fd28a35b | |||
| 15c35c49de | |||
| a49d8d99b6 | |||
| aa9d689c76 | |||
| b59d4484ec | |||
| cd23119470 | |||
| 18ab639669 | |||
| fc03905777 | |||
| ea8152ebf3 | |||
| 78973e3fbf | |||
| 317f910f3c | |||
| d36fe13bcd | |||
| 15b878f2a4 | |||
| 09eda9a11e | |||
| 23c0cc7a5f | |||
| 1725b9e4b4 | |||
| cc9933d1ce | |||
| b810cabc6d | |||
| 25aee745cc | |||
| 097c7dec4b | |||
| 92cf51ba3d | |||
| 94d2a88581 | |||
| da21cd3daa | |||
| 19cb312465 | |||
| 2b7ef65cce | |||
| 745c192693 | |||
| 34532dc9bf | |||
| f053908acb | |||
| 959e0b66cb | |||
| ac1e0a9f13 | |||
| 2a208d5dea | |||
| 82c447b193 | |||
| 75765bf9d4 | |||
| 6479365d6a | |||
| d11757c854 | |||
| 479de04f7c | |||
| cebeefdb22 | |||
| e93e4a7721 | |||
| cd6b82abd8 | |||
| 047ffd7b16 | |||
| bca1d8afea | |||
| 0418370c31 | |||
| cd206d6baa | |||
| aeb72154a7 | |||
| e0d3c840a2 | |||
| f2303f7fb8 | |||
| fb530e2ff5 | |||
| 8c4419e04e | |||
| 85f2c156b0 | |||
| addfcfb0b1 | |||
| b392c28787 | |||
| b534a569f8 | |||
| b0f565103f | |||
| 5e33dd9847 | |||
| 9a550cbaaf | |||
| 823e439101 | |||
| 6c299f1a1d | |||
| 7c9827875f | |||
| ae7c63c08d | |||
| 270cebc79a | |||
| ba21cc89c6 | |||
| 56dfffd126 | |||
| bdf80e577b | |||
| eb96b36d0b | |||
| 36bec94639 | |||
| c11c7647b9 | |||
| 83ed90ef67 | |||
| 73dbf6effb | |||
| b8adfe98a3 | |||
| 6c6fb10490 | |||
| 935ab0d1d4 | |||
| 350a086a73 | |||
| c5f526d51d | |||
| e1f5130c18 | |||
| bc0b0cafd6 | |||
| ade68972a9 | |||
| 47b39e3ba0 | |||
| cf73bbae23 | |||
| d1b207422c | |||
| 4a29d8297c | |||
| 792b543ba8 | |||
| 76270b20bf | |||
| c2914c37e5 | |||
| 9f9b7cb2bd | |||
| de396442a4 | |||
| 02efe6fa14 | |||
| 14b867a3a5 | |||
| f750fea667 | |||
| 55e91158b3 | |||
| 19cb13d26c | |||
| 04253b9120 | |||
| aaf6ee81d5 | |||
| 972bdde650 | |||
| c0d66cfe50 | |||
| 00b52b9aaa | |||
| e9537ce052 | |||
| c204fb0a92 | |||
| c643b0b979 | |||
| ed44e88e93 | |||
| 57172d54d5 | |||
| 608e405468 | |||
| b36f2ea63a | |||
| 7fa2c6fbed | |||
| bde85a7b82 | |||
| b4ec9387a0 | |||
| f6d97b4ee8 | |||
| f1dc23c54c | |||
| 9e6dc8c85c | |||
| fd5427c7ea | |||
| 4de570f5f4 | |||
| 22845e5d5f | |||
| 8d7507e2d8 | |||
| 48995aace4 | |||
| 5a26841d3a | |||
| 92ad0e16cf | |||
| 7b14f4a0cc | |||
| d69bfa359a | |||
| d3d56150c5 | |||
| 6a5ddcc70a | |||
| 5c55d3c385 | |||
| b30adfea8e | |||
| 02aecbeaab | |||
| 734a15c9ce | |||
| 9a3ccabcc9 | |||
| fd62b5bdbe | |||
| 966ddc9219 | |||
| 52a809649c | |||
| e307550ead | |||
| 372c29bc3a | |||
| 9c45011bd4 | |||
| acc5b78471 | |||
| d70f2d8f62 | |||
| 5fe1ff1fc3 | |||
| 0f5f0f4c2b | |||
| f15d2057f2 | |||
| 68aa7d3ce2 |
@@ -23,7 +23,7 @@
|
|||||||
| Guard | 用途 |
|
| Guard | 用途 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| JwtAuthGuard | 需登录 |
|
| JwtAuthGuard | 需登录 |
|
||||||
| PhoneVerifiedGuard | C 端需绑定手机(下单/支付) |
|
| PhoneVerifiedGuard | (已废弃强制)C 端手机号改为下单页可选绑定 |
|
||||||
| OptionalJwtAuthGuard | 可选登录(bootstrap) |
|
| OptionalJwtAuthGuard | 可选登录(bootstrap) |
|
||||||
|
|
||||||
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: dukang-release
|
||||||
|
description: >-
|
||||||
|
杜康好客生产发布流程:dev_jacy 提交合并到 dev,并用 deploy.sh 发版。
|
||||||
|
Use when the user says 发布、上传、deploy、上线、发版, or @杜康发布 / @dukang-release.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 杜康发布
|
||||||
|
|
||||||
|
将已完成改动发到生产(`aliyun-smarthr`)。**仅用户明确要求发布时执行。**
|
||||||
|
|
||||||
|
## 分支约定
|
||||||
|
|
||||||
|
| 分支 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `dev_jacy` | 日常开发 / 提交 |
|
||||||
|
| `dev` | **发布分支**(服务器拉这个) |
|
||||||
|
|
||||||
|
默认:`dev_jacy` → merge → `dev` → 部署。勿 force push `main`/`dev`。
|
||||||
|
|
||||||
|
## 标准流程(Windows)
|
||||||
|
|
||||||
|
PowerShell 下 **分支名必须加引号**(否则 `checkout` 与 `dev` 会被粘成 `checkoutdev`)。
|
||||||
|
|
||||||
|
### 1. 提交(在 `dev_jacy`)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status -sb
|
||||||
|
git add <files>
|
||||||
|
git commit -m @"
|
||||||
|
fix(scope): summary
|
||||||
|
|
||||||
|
Optional body.
|
||||||
|
"@
|
||||||
|
```
|
||||||
|
|
||||||
|
- Conventional Commits;不提交 `.env` / `dist` / `node_modules`
|
||||||
|
- 用户未要求 commit 时不要擅自提交
|
||||||
|
|
||||||
|
### 2. 推送并合并到 `dev`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git push -u origin HEAD
|
||||||
|
git checkout "dev"
|
||||||
|
git pull origin "dev"
|
||||||
|
git merge "dev_jacy" -m "merge(dev_jacy): <简述>"
|
||||||
|
git push origin "dev"
|
||||||
|
git checkout "dev_jacy"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 远程发版
|
||||||
|
|
||||||
|
本机无 WSL bash 时用 **Git Bash**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy.sh" --branch "dev" -- --skip-db
|
||||||
|
```
|
||||||
|
|
||||||
|
- 默认加 `--skip-db`(无迁移时)
|
||||||
|
- 有 Prisma 迁移时去掉 `--skip-db`,按用户确认是否 `--seed` / `--accept-data-loss`
|
||||||
|
- `deploy/deploy.env` 提供 `DEPLOY_HOST` 等(勿提交密钥)
|
||||||
|
|
||||||
|
### 4. 等待完成
|
||||||
|
|
||||||
|
后台跑部署时轮询终端输出,成功标志:
|
||||||
|
|
||||||
|
- 出现 `发版完成`
|
||||||
|
- `exit_code: 0`
|
||||||
|
- `mini-user(h5): 200`(或对应前端 200)
|
||||||
|
|
||||||
|
说明:API 健康检查短暂 `FAIL`/`000` 多为重启中,以 `发版完成` + `exit_code: 0` 为准。
|
||||||
|
|
||||||
|
### 5. 回复用户
|
||||||
|
|
||||||
|
一句说明:已合并 commit、发版结果、建议微信强刷验证。
|
||||||
|
|
||||||
|
## 常用变体
|
||||||
|
|
||||||
|
| 场景 | 做法 |
|
||||||
|
|------|------|
|
||||||
|
| 仅部署已推送的 `dev` | 跳过 commit/merge,直接跑 `deploy.sh --branch "dev" -- --skip-db` |
|
||||||
|
| 检查服务器 | `deploy.sh --branch "dev" --check` |
|
||||||
|
| 含 DB | `deploy.sh --branch "dev" --`(无 skip-db)或按用户给 seed 参数 |
|
||||||
|
|
||||||
|
## 禁止
|
||||||
|
|
||||||
|
- 不 amend 已推送 commit;不 force push `main`/`master`/`dev`
|
||||||
|
- 不把 `.env` / 私钥打进仓库
|
||||||
|
- 用户只说「修好」未说发布时 → **只改代码,不发版**
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
|-----|------|----------|
|
|-----|------|----------|
|
||||||
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
|
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
|
||||||
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
|
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
|
||||||
| ACC-012 | 代下单 | W3:手机号建用户;权益自动发 |
|
| ACC-012 | 代下单 | W3:客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 |
|
||||||
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
|
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
|
||||||
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
|
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
|
||||||
| ACC-018 | 话术 | OPT-012 3 日内对齐 |
|
| ACC-018 | 话术 | OPT-012 3 日内对齐 |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| ID | 模块 | 结论摘要 |
|
| ID | 模块 | 结论摘要 |
|
||||||
|----|------|----------|
|
|----|------|----------|
|
||||||
| REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 |
|
| REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 |
|
||||||
| REQ-U-002 | 登录 | 无感登录;下单强制手机号;微信/短信;7 天免登 |
|
| REQ-U-002 | 登录 | 无感登录;下单提示绑定手机号(可选);微信/短信;7 天免登 |
|
||||||
| REQ-U-003 | 支付 | 仅微信支付 |
|
| REQ-U-003 | 支付 | 仅微信支付 |
|
||||||
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
|
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
|
||||||
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
|
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ coverage/
|
|||||||
server/dukang-api/prisma/migrations/
|
server/dukang-api/prisma/migrations/
|
||||||
debug_v3.xlsx
|
debug_v3.xlsx
|
||||||
~$debug_v3.xlsx
|
~$debug_v3.xlsx
|
||||||
|
deploy/auto-release.env
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
|||||||
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
|
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
|
||||||
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
|
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
|
||||||
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
|
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
|
||||||
|
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
|
||||||
|
|
||||||
## 子 Agent(按职责选用)
|
## 子 Agent(按职责选用)
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
|||||||
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
|
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
|
||||||
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
|
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
|
||||||
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
|
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
|
||||||
|
| `dukang-release`(杜康发布) | `dev_jacy`→`dev` 合并 + `deploy.sh` 发版 |
|
||||||
|
|
||||||
## 快速指令
|
## 快速指令
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import HqAccountsPage from './pages/HqAccountsPage';
|
|||||||
import CitiesPage from './pages/CitiesPage';
|
import CitiesPage from './pages/CitiesPage';
|
||||||
import CityPartnersPage from './pages/CityPartnersPage';
|
import CityPartnersPage from './pages/CityPartnersPage';
|
||||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||||
|
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
||||||
import StoreMediaPage from './pages/StoreMediaPage';
|
import StoreMediaPage from './pages/StoreMediaPage';
|
||||||
|
import StoreCategoriesPage from './pages/StoreCategoriesPage';
|
||||||
import PromoCodesPage from './pages/PromoCodesPage';
|
import PromoCodesPage from './pages/PromoCodesPage';
|
||||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||||
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
||||||
@@ -29,7 +31,10 @@ import ResourcesPage from './pages/ResourcesPage';
|
|||||||
import StoreBillsPage from './pages/StoreBillsPage';
|
import StoreBillsPage from './pages/StoreBillsPage';
|
||||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||||
import WineryBillsPage from './pages/WineryBillsPage';
|
import WineryBillsPage from './pages/WineryBillsPage';
|
||||||
|
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
||||||
import TicketsPage from './pages/TicketsPage';
|
import TicketsPage from './pages/TicketsPage';
|
||||||
|
import SupportTicketsPage from './pages/SupportTicketsPage';
|
||||||
|
import InvoicesPage from './pages/InvoicesPage';
|
||||||
import UserLogsPage from './pages/UserLogsPage';
|
import UserLogsPage from './pages/UserLogsPage';
|
||||||
import HqLogsPage from './pages/HqLogsPage';
|
import HqLogsPage from './pages/HqLogsPage';
|
||||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||||
@@ -38,6 +43,9 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
|||||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||||
|
import WecomBotsPage from './pages/WecomBotsPage';
|
||||||
|
import LlmConfigsPage from './pages/LlmConfigsPage';
|
||||||
|
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
if (!getToken()) return <Navigate to="/login" replace />;
|
if (!getToken()) return <Navigate to="/login" replace />;
|
||||||
@@ -64,9 +72,13 @@ export default function App() {
|
|||||||
<Route index element={<PromoCodeDetailPage />} />
|
<Route index element={<PromoCodeDetailPage />} />
|
||||||
<Route path="users" element={<PromoCodeUsersPage />} />
|
<Route path="users" element={<PromoCodeUsersPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/wecom-bots" element={<WecomBotsPage />} />
|
||||||
|
<Route path="/llm-configs" element={<LlmConfigsPage />} />
|
||||||
|
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
|
||||||
<Route path="/products" element={<ProductsPage />} />
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||||
<Route path="/stores" element={<StoresPage />} />
|
<Route path="/stores" element={<StoresPage />} />
|
||||||
|
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
||||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||||
<Route path="/resources" element={<ResourcesPage />} />
|
<Route path="/resources" element={<ResourcesPage />} />
|
||||||
@@ -74,6 +86,7 @@ export default function App() {
|
|||||||
<Route path="/cities" element={<CitiesPage />} />
|
<Route path="/cities" element={<CitiesPage />} />
|
||||||
<Route path="/city-partners" element={<CityPartnersPage />} />
|
<Route path="/city-partners" element={<CityPartnersPage />} />
|
||||||
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
|
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
|
||||||
|
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
|
||||||
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
|
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
|
||||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||||
@@ -83,10 +96,13 @@ export default function App() {
|
|||||||
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
||||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
||||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||||
|
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
||||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||||
<Route path="/tickets" element={<TicketsPage />} />
|
<Route path="/tickets" element={<TicketsPage />} />
|
||||||
|
<Route path="/tickets/support" element={<SupportTicketsPage />} />
|
||||||
|
<Route path="/invoices" element={<InvoicesPage />} />
|
||||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||||
<Route path="/logs/partners" element={<PartnerLogsPage />} />
|
<Route path="/logs/partners" element={<PartnerLogsPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Select } from 'antd';
|
||||||
|
import { getDistrictOptionsByCityCode } from '../lib/china-region';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cityCode?: string | null;
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (value: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 已选开城城市后,仅多选该市下区县(不再选省/市) */
|
||||||
|
export default function CityDistrictMultiSelect({
|
||||||
|
cityCode,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
}: Props) {
|
||||||
|
const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]);
|
||||||
|
const ready = Boolean(cityCode) && options.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled || !ready}
|
||||||
|
placeholder={
|
||||||
|
placeholder ??
|
||||||
|
(cityCode ? (ready ? '请选择区县(可多选)' : '该城市暂无区县数据') : '请先选择开城城市')
|
||||||
|
}
|
||||||
|
options={options}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Cascader,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
@@ -27,8 +26,9 @@ import {
|
|||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||||
|
|
||||||
type PartnerRow = {
|
type PartnerRow = {
|
||||||
@@ -37,6 +37,7 @@ type PartnerRow = {
|
|||||||
phone: string;
|
phone: string;
|
||||||
name: string;
|
name: string;
|
||||||
scopeType?: string;
|
scopeType?: string;
|
||||||
|
districtCodes?: string[] | null;
|
||||||
orderCommissionRate?: number;
|
orderCommissionRate?: number;
|
||||||
redeemCommissionRate?: number;
|
redeemCommissionRate?: number;
|
||||||
accountCount: number;
|
accountCount: number;
|
||||||
@@ -65,6 +66,7 @@ type PartnerDetail = PartnerRow & {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
cityId: string;
|
cityId: string;
|
||||||
|
cityCode?: string;
|
||||||
maxPartnerCommissionRate?: number;
|
maxPartnerCommissionRate?: number;
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
};
|
};
|
||||||
@@ -92,14 +94,31 @@ function commissionSumError(
|
|||||||
redeemPercent: number,
|
redeemPercent: number,
|
||||||
maxRate: number,
|
maxRate: number,
|
||||||
): string | null {
|
): string | null {
|
||||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||||
if (sum > maxRate + 1e-9) {
|
const max = Number(maxRate);
|
||||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||||
|
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||||
|
if (sum > max + 1e-9) {
|
||||||
|
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) {
|
function formatApiError(err: unknown): string | null {
|
||||||
|
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||||
|
if (!(err instanceof Error)) return '操作失败';
|
||||||
|
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||||
|
const label = districtCodeLabel(code);
|
||||||
|
return label !== code ? `${label}(${code})` : code;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CityPartnersPanel({
|
||||||
|
cityId,
|
||||||
|
cityCode,
|
||||||
|
maxPartnerCommissionRate = 0.05,
|
||||||
|
onChanged,
|
||||||
|
}: Props) {
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [subForm] = Form.useForm();
|
const [subForm] = Form.useForm();
|
||||||
@@ -153,29 +172,34 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
|
|
||||||
async function savePartner() {
|
async function savePartner() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const v = await editForm.validateFields();
|
try {
|
||||||
const err = commissionSumError(
|
const v = await editForm.validateFields();
|
||||||
Number(v.orderCommissionRate ?? 0),
|
const err = commissionSumError(
|
||||||
Number(v.redeemCommissionRate ?? 0),
|
Number(v.orderCommissionRate ?? 0),
|
||||||
maxPartnerCommissionRate,
|
Number(v.redeemCommissionRate ?? 0),
|
||||||
);
|
maxPartnerCommissionRate,
|
||||||
if (err) {
|
);
|
||||||
message.error(err);
|
if (err) {
|
||||||
return;
|
message.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await request(`/admin/partners/${detail.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...v,
|
||||||
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
void loadPartners();
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
}
|
}
|
||||||
await request(`/admin/partners/${detail.id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({
|
|
||||||
...v,
|
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
|
||||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
message.success('已保存');
|
|
||||||
setDrawerOpen(false);
|
|
||||||
void loadPartners();
|
|
||||||
onChanged?.();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSubAccount(subId: string) {
|
async function deleteSubAccount(subId: string) {
|
||||||
@@ -187,6 +211,14 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<PartnerRow> = [
|
const columns: ColumnsType<PartnerRow> = [
|
||||||
|
{
|
||||||
|
title: '区县',
|
||||||
|
dataIndex: 'districtCodes',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
|
},
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
@@ -249,11 +281,11 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
label: '主账号',
|
label: '主账号',
|
||||||
children: (
|
children: (
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||||
<Select options={BINDING_OPTIONS} />
|
<Select options={BINDING_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -261,8 +293,12 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
<Form.Item name="districtCodes" label="区县">
|
<Form.Item
|
||||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
name="districtCodes"
|
||||||
|
label="区县"
|
||||||
|
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
|
>
|
||||||
|
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
<Space style={{ width: '100%' }} size="large">
|
<Space style={{ width: '100%' }} size="large">
|
||||||
@@ -292,7 +328,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
subs={detail.children ?? []}
|
subs={detail.children ?? []}
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}}
|
}}
|
||||||
onEdit={(row) => {
|
onEdit={(row) => {
|
||||||
@@ -316,42 +352,51 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||||
const v = await createForm.validateFields();
|
try {
|
||||||
const err = commissionSumError(
|
const v = await createForm.validateFields();
|
||||||
Number(v.orderCommissionRate ?? 0),
|
const err = commissionSumError(
|
||||||
Number(v.redeemCommissionRate ?? 3),
|
Number(v.orderCommissionRate ?? 0),
|
||||||
maxPartnerCommissionRate,
|
Number(v.redeemCommissionRate ?? 3),
|
||||||
);
|
maxPartnerCommissionRate,
|
||||||
if (err) {
|
);
|
||||||
message.error(err);
|
if (err) {
|
||||||
return;
|
message.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await request('/admin/partners', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...v,
|
||||||
|
cityId,
|
||||||
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
|
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已创建');
|
||||||
|
setCreateOpen(false);
|
||||||
|
void loadPartners();
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
}
|
}
|
||||||
await request('/admin/partners', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
...v,
|
|
||||||
cityId,
|
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
|
||||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
message.success('已创建');
|
|
||||||
setCreateOpen(false);
|
|
||||||
void loadPartners();
|
|
||||||
onChanged?.();
|
|
||||||
}}>
|
}}>
|
||||||
<Form form={createForm} layout="vertical">
|
<Form form={createForm} layout="vertical">
|
||||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
name="districtCodes"
|
||||||
|
label="区县"
|
||||||
|
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
|
>
|
||||||
|
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
<Space style={{ width: '100%' }} size="large">
|
<Space style={{ width: '100%' }} size="large">
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Space } from 'antd';
|
||||||
|
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
parseMiniHomeBanners,
|
||||||
|
serializeMiniHomeBanners,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import OssUpload from './OssUpload';
|
||||||
|
|
||||||
|
const MAX_BANNERS = 8;
|
||||||
|
|
||||||
|
/** 编辑态保留空位;下发/入库仍用 parseMiniHomeBanners 过滤空串 */
|
||||||
|
function parseBannersForEdit(raw?: string | null): string[] {
|
||||||
|
if (!raw?.trim()) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw.trim()) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return parseMiniHomeBanners(raw);
|
||||||
|
return parsed
|
||||||
|
.filter((u): u is string => typeof u === 'string')
|
||||||
|
.map((u) => u.trim())
|
||||||
|
.slice(0, MAX_BANNERS);
|
||||||
|
} catch {
|
||||||
|
return parseMiniHomeBanners(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer) */
|
||||||
|
export function ConfigImageField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
bizType = 'footer',
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (url: string) => void;
|
||||||
|
bizType?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OssUpload
|
||||||
|
bizType={bizType}
|
||||||
|
mediaType="IMAGE"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(url) => onChange?.(url ?? '')}
|
||||||
|
placeholder="上传或粘贴图片 URL"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ant Form 控件:多图 JSON 字符串(默认 OSS 路径 swiper) */
|
||||||
|
export function ConfigImageListField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
bizType = 'swiper',
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (json: string) => void;
|
||||||
|
bizType?: string;
|
||||||
|
}) {
|
||||||
|
const [urls, setUrls] = useState<string[]>(() => parseBannersForEdit(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUrls(parseBannersForEdit(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
/** 本地可含空位;写入 Form 时去掉空串 */
|
||||||
|
function commit(next: string[]) {
|
||||||
|
const clipped = next.slice(0, MAX_BANNERS);
|
||||||
|
setUrls(clipped);
|
||||||
|
onChange?.(serializeMiniHomeBanners(clipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAt(index: number, url: string) {
|
||||||
|
const next = [...urls];
|
||||||
|
next[index] = url;
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
commit(urls.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(index: number, delta: number) {
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= urls.length) return;
|
||||||
|
const next = [...urls];
|
||||||
|
const tmp = next[index];
|
||||||
|
next[index] = next[target];
|
||||||
|
next[target] = tmp;
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
if (urls.length >= MAX_BANNERS) return;
|
||||||
|
// 只加本地空位,避免 serialize 过滤空串导致「点击无反应」
|
||||||
|
setUrls((prev) => [...prev, '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
{urls.map((url, index) => (
|
||||||
|
<Space key={`banner-${index}`} align="start" style={{ width: '100%' }} wrap>
|
||||||
|
<div style={{ flex: 1, minWidth: 240 }}>
|
||||||
|
<OssUpload
|
||||||
|
bizType={bizType}
|
||||||
|
mediaType="IMAGE"
|
||||||
|
value={url}
|
||||||
|
onChange={(u) => updateAt(index, u)}
|
||||||
|
placeholder="上传或粘贴图片 URL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowUpOutlined />}
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => move(index, -1)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowDownOutlined />}
|
||||||
|
disabled={index === urls.length - 1}
|
||||||
|
onClick={() => move(index, 1)}
|
||||||
|
/>
|
||||||
|
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeAt(index)} />
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
block
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
disabled={urls.length >= MAX_BANNERS}
|
||||||
|
onClick={add}
|
||||||
|
>
|
||||||
|
添加轮播图({urls.length}/{MAX_BANNERS})
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import {
|
||||||
|
placeToPicked,
|
||||||
|
type LbsPlaceItem,
|
||||||
|
type TencentPickedLocation,
|
||||||
|
} from '../lib/tencentLocPicker';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onPick: (loc: TencentPickedLocation) => void;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
/** 城市名,提升搜索相关性 */
|
||||||
|
region?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasCoords(lat: unknown, lng: unknown): lat is number {
|
||||||
|
const a = typeof lat === 'number' ? lat : Number(lat);
|
||||||
|
const b = typeof lng === 'number' ? lng : Number(lng);
|
||||||
|
return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TencentLocPickerModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onPick,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
region,
|
||||||
|
}: Props) {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [items, setItems] = useState<LbsPlaceItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
|
||||||
|
const [hint, setHint] = useState('输入地点名称搜索,或加载附近地点');
|
||||||
|
const seqRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setKeyword('');
|
||||||
|
setItems([]);
|
||||||
|
setPending(null);
|
||||||
|
setHint('输入地点名称搜索,或加载附近地点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lat = latitude != null ? Number(latitude) : NaN;
|
||||||
|
const lng = longitude != null ? Number(longitude) : NaN;
|
||||||
|
if (hasCoords(lat, lng)) {
|
||||||
|
void loadNearby(lat, lng);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function loadNearby(lat: number, lng: number) {
|
||||||
|
const seq = ++seqRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setHint('正在加载附近地点…');
|
||||||
|
try {
|
||||||
|
const res = await request<{ items: LbsPlaceItem[] }>(
|
||||||
|
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
|
||||||
|
);
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setHint(res.items?.length ? `附近 ${res.items.length} 个地点,点击选择` : '附近暂无地点,请搜索');
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
const msg = e instanceof Error ? e.message : '加载附近地点失败';
|
||||||
|
setItems([]);
|
||||||
|
setHint(msg);
|
||||||
|
message.error(msg);
|
||||||
|
} finally {
|
||||||
|
if (seq === seqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearch(q: string) {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
message.warning('请输入搜索关键词');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seq = ++seqRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setHint('搜索中…');
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ keyword: trimmed });
|
||||||
|
if (region?.trim()) params.set('region', region.trim());
|
||||||
|
const lat = latitude != null ? Number(latitude) : NaN;
|
||||||
|
const lng = longitude != null ? Number(longitude) : NaN;
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||||
|
params.set('lat', String(lat));
|
||||||
|
params.set('lng', String(lng));
|
||||||
|
}
|
||||||
|
const res = await request<{ items: LbsPlaceItem[] }>(`/common/lbs/suggest?${params.toString()}`);
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setHint(res.items?.length ? `找到 ${res.items.length} 个结果,点击选择` : '无匹配结果,换个关键词试试');
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
const msg = e instanceof Error ? e.message : '搜索失败';
|
||||||
|
setItems([]);
|
||||||
|
setHint(msg);
|
||||||
|
message.error(msg);
|
||||||
|
} finally {
|
||||||
|
if (seq === seqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBrowserLocation() {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
message.error('当前浏览器不支持定位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const lat = pos.coords.latitude;
|
||||||
|
const lng = pos.coords.longitude;
|
||||||
|
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
|
||||||
|
void loadNearby(lat, lng);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setLoading(false);
|
||||||
|
message.error('定位失败,请检查浏览器定位权限');
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 12000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmPick() {
|
||||||
|
if (!pending) {
|
||||||
|
message.warning('请先从列表中选择一个地点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPick(pending);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="地图选点(腾讯位置服务)"
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
footer={
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Text type="secondary" style={{ maxWidth: 420 }} ellipsis>
|
||||||
|
{pending
|
||||||
|
? `${pending.latitude.toFixed(6)}, ${pending.longitude.toFixed(6)}${
|
||||||
|
pending.name ? ` · ${pending.name}` : ''
|
||||||
|
}`
|
||||||
|
: '搜索或选择附近地点后确认'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button type="primary" disabled={!pending} onClick={confirmPick}>
|
||||||
|
确认选点
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="输入小区 / 写字楼 / 门店名称"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onPressEnter={() => void runSearch(keyword)}
|
||||||
|
/>
|
||||||
|
<Button type="primary" loading={loading} onClick={() => void runSearch(keyword)}>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
<Space wrap>
|
||||||
|
<Button onClick={useBrowserLocation} disabled={loading}>
|
||||||
|
定位当前位置
|
||||||
|
</Button>
|
||||||
|
<Typography.Text type="secondary">{hint}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<div style={{ height: 420, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||||
|
{loading && !items.length ? (
|
||||||
|
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Spin tip="加载中…" />
|
||||||
|
</div>
|
||||||
|
) : items.length ? (
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={items}
|
||||||
|
renderItem={(item) => {
|
||||||
|
const active =
|
||||||
|
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: active ? 'rgba(22, 119, 255, 0.08)' : undefined,
|
||||||
|
paddingInline: 12,
|
||||||
|
}}
|
||||||
|
onClick={() => setPending(placeToPicked(item))}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={item.title}
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
{item.address}
|
||||||
|
<br />
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
|
||||||
|
</Typography.Text>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty style={{ marginTop: 80 }} description={hint} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
|
RobotOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
ShoppingOutlined,
|
ShoppingOutlined,
|
||||||
@@ -18,11 +19,14 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
AccountBookOutlined,
|
AccountBookOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
type MenuItem = NonNullable<MenuProps['items']>[number];
|
||||||
|
|
||||||
const MENU_ITEMS: MenuProps['items'] = [
|
const MENU_ITEMS: MenuProps['items'] = [
|
||||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||||
@@ -38,12 +42,16 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
},
|
},
|
||||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||||
|
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
|
||||||
|
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||||
|
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
||||||
{
|
{
|
||||||
key: 'stores-group',
|
key: 'stores-group',
|
||||||
icon: <ShopOutlined />,
|
icon: <ShopOutlined />,
|
||||||
label: '门店',
|
label: '门店',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/stores', label: '门店列表' },
|
{ key: '/stores', label: '门店列表' },
|
||||||
|
{ key: '/store-categories', label: '门店分类' },
|
||||||
{ key: '/store-accounts', label: '门店账户' },
|
{ key: '/store-accounts', label: '门店账户' },
|
||||||
{ key: '/store-media', label: '门店资源' },
|
{ key: '/store-media', label: '门店资源' },
|
||||||
],
|
],
|
||||||
@@ -56,6 +64,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/cities', label: '城市' },
|
{ key: '/cities', label: '城市' },
|
||||||
{ key: '/city-partners', label: '城市合伙人' },
|
{ key: '/city-partners', label: '城市合伙人' },
|
||||||
{ key: '/city-warehouses', label: '仓库' },
|
{ key: '/city-warehouses', label: '仓库' },
|
||||||
|
{ key: '/fulfillment-providers', label: '仓配管理' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,6 +75,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/finance/store-bills', label: '门店账单' },
|
{ key: '/finance/store-bills', label: '门店账单' },
|
||||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||||
|
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,7 +99,16 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
{
|
||||||
|
key: 'tickets-group',
|
||||||
|
icon: <CarOutlined />,
|
||||||
|
label: '工单',
|
||||||
|
children: [
|
||||||
|
{ key: '/tickets', label: '工单中心' },
|
||||||
|
{ key: '/tickets/support', label: '技术支持' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||||
{
|
{
|
||||||
key: 'logs-group',
|
key: 'logs-group',
|
||||||
@@ -108,6 +127,83 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||||
|
if (key === 'tickets-group') {
|
||||||
|
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
||||||
|
}
|
||||||
|
const map: Record<string, string | 'system_settings_any'> = {
|
||||||
|
'/': 'dashboard',
|
||||||
|
'/users': 'users',
|
||||||
|
'/wechat-bindings': 'wechat_bindings',
|
||||||
|
'products-group': 'products',
|
||||||
|
'/products': 'products',
|
||||||
|
'/product-detail-templates': 'products',
|
||||||
|
'/orders': 'orders',
|
||||||
|
'/promo-codes': 'promo_codes',
|
||||||
|
'/wecom-bots': 'wecom_bots',
|
||||||
|
'/llm-configs': 'llm_configs',
|
||||||
|
'/knowledge-bases': 'knowledge_bases',
|
||||||
|
'stores-group': 'stores',
|
||||||
|
'/stores': 'stores',
|
||||||
|
'/store-categories': 'stores',
|
||||||
|
'/store-accounts': 'stores',
|
||||||
|
'/store-media': 'stores',
|
||||||
|
'partners-group': 'partners',
|
||||||
|
'/cities': 'partners',
|
||||||
|
'/city-partners': 'partners',
|
||||||
|
'/city-warehouses': 'partners',
|
||||||
|
'/fulfillment-providers': 'partners',
|
||||||
|
'finance-group': 'finance',
|
||||||
|
'/finance/store-bills': 'finance',
|
||||||
|
'/finance/partner-bills': 'finance',
|
||||||
|
'/finance/winery-bills': 'finance',
|
||||||
|
'/finance/logistics-bills': 'finance',
|
||||||
|
'benefit-group': 'benefit',
|
||||||
|
'/benefit/coupons': 'benefit',
|
||||||
|
'/benefit/ledgers': 'benefit',
|
||||||
|
'/redeem-records': 'benefit',
|
||||||
|
'/redeem-pending': 'benefit',
|
||||||
|
'/redeem/debug': 'benefit',
|
||||||
|
'deliveries-group': 'deliveries',
|
||||||
|
'/deliveries': 'deliveries',
|
||||||
|
'/deliveries/xiaofeixia': 'deliveries',
|
||||||
|
'/tickets': 'tickets',
|
||||||
|
'/tickets/support': 'tech_support',
|
||||||
|
'/invoices': 'invoices',
|
||||||
|
'/resources': 'resources',
|
||||||
|
'logs-group': 'logs',
|
||||||
|
'/logs/users': 'logs',
|
||||||
|
'/logs/stores': 'logs',
|
||||||
|
'/logs/partners': 'logs',
|
||||||
|
'/logs/hq': 'logs',
|
||||||
|
'/logs/third-party': 'logs',
|
||||||
|
'/hq-permissions': 'hq_permissions',
|
||||||
|
'/system-settings': 'system_settings_any',
|
||||||
|
'/hq-accounts': 'hq_accounts',
|
||||||
|
};
|
||||||
|
const need = map[key];
|
||||||
|
if (!need) return true;
|
||||||
|
if (need === 'system_settings_any') return hasAnySystemSettingsPermission(permissionKeys);
|
||||||
|
return permissionKeys.includes(need);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): MenuProps['items'] {
|
||||||
|
if (!items) return items;
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||||
|
const key = String(item.key);
|
||||||
|
if ('children' in item && Array.isArray(item.children)) {
|
||||||
|
if (!menuAllowed(key, permissionKeys)) return null;
|
||||||
|
const children = filterMenuItems(item.children as MenuProps['items'], permissionKeys);
|
||||||
|
if (!children?.length) return null;
|
||||||
|
return { ...item, children } as MenuItem;
|
||||||
|
}
|
||||||
|
return menuAllowed(key, permissionKeys) ? item : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as MenuProps['items'];
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -135,6 +231,12 @@ export default function AdminLayout() {
|
|||||||
? '/promo-codes'
|
? '/promo-codes'
|
||||||
: location.pathname;
|
: location.pathname;
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
if (!profile) return MENU_ITEMS;
|
||||||
|
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
|
||||||
|
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
<Sider
|
<Sider
|
||||||
@@ -153,8 +255,8 @@ export default function AdminLayout() {
|
|||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
|
defaultOpenKeys={[]}
|
||||||
items={MENU_ITEMS}
|
items={menuItems}
|
||||||
onClick={({ key }) => {
|
onClick={({ key }) => {
|
||||||
if (key.startsWith('/')) navigate(key);
|
if (key.startsWith('/')) navigate(key);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type HqProfile = {
|
|||||||
name: string;
|
name: string;
|
||||||
adminRole: string;
|
adminRole: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
permissionKeys?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getToken() {
|
export function getToken() {
|
||||||
@@ -79,6 +80,11 @@ export type DashboardStats = {
|
|||||||
partnersTotal: number;
|
partnersTotal: number;
|
||||||
redeemToday: number;
|
redeemToday: number;
|
||||||
deliveriesTotal: number;
|
deliveriesTotal: number;
|
||||||
|
pendingPayouts?: number;
|
||||||
|
/** 合伙人已确认、待总部审核打款 */
|
||||||
|
pendingBills?: number;
|
||||||
|
pendingPartnerDraftBills?: number;
|
||||||
|
openTickets?: number;
|
||||||
ordersByStatus: Array<{ status: string; count: number }>;
|
ordersByStatus: Array<{ status: string; count: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,15 +122,38 @@ export type AdminUserRow = {
|
|||||||
orderCount: number;
|
orderCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminOrderItem = {
|
||||||
|
productName: string;
|
||||||
|
productSpec: string;
|
||||||
|
productImage: string;
|
||||||
|
unitPrice: number;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminOrderRow = {
|
export type AdminOrderRow = {
|
||||||
id: string;
|
id: string;
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
status: string;
|
status: string;
|
||||||
deliveryType: string;
|
deliveryType: string;
|
||||||
payAmount: number;
|
payAmount: number;
|
||||||
|
productName?: string;
|
||||||
|
productSpec?: string;
|
||||||
|
quantity?: number;
|
||||||
receiverName: string;
|
receiverName: string;
|
||||||
receiverPhone: string;
|
receiverPhone: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
cityId?: string;
|
||||||
|
city?: { id: string; name: string; code: string };
|
||||||
|
fulfillmentWarehouseId?: string | null;
|
||||||
|
fulfillmentWarehouse?: { id: string; name: string } | null;
|
||||||
|
fulfillmentHold?: boolean;
|
||||||
|
fulfillmentHoldReason?: string | null;
|
||||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||||
delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null };
|
delivery?: {
|
||||||
|
provider: string;
|
||||||
|
trackingNo: string | null;
|
||||||
|
providerOrderNo: string | null;
|
||||||
|
logisticsCompany?: string | null;
|
||||||
|
manualQueryUrl?: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,6 +79,39 @@ export function districtCodeToCityCode(districtCode: string): string {
|
|||||||
return `${districtCode.slice(0, 4)}00`;
|
return `${districtCode.slice(0, 4)}00`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DistrictOption = { value: string; label: string };
|
||||||
|
|
||||||
|
/** 按开城城市 code(4 位或 6 位)取该市下区县列表 */
|
||||||
|
export function getDistrictOptionsByCityCode(cityCode?: string | null): DistrictOption[] {
|
||||||
|
if (!cityCode?.trim()) return [];
|
||||||
|
const normalized = normalizeCityAdcode(cityCode.trim());
|
||||||
|
const prefix4 = normalized.slice(0, 4);
|
||||||
|
|
||||||
|
for (const province of regionData) {
|
||||||
|
for (const city of province.children ?? []) {
|
||||||
|
const cityValue = String(city.value);
|
||||||
|
const cityNorm = normalizeCityAdcode(cityValue);
|
||||||
|
if (cityNorm === normalized || cityValue === prefix4 || cityValue === cityCode.trim()) {
|
||||||
|
return (city.children ?? []).map((d) => ({
|
||||||
|
value: String(d.value),
|
||||||
|
label: String(d.label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function districtCodeLabel(code: string): string {
|
||||||
|
return codeToText[code] || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表展示:区县码 → 中文名,顿号拼接 */
|
||||||
|
export function formatDistrictLabels(codes?: string[] | null): string {
|
||||||
|
if (!codes?.length) return '—';
|
||||||
|
return codes.map((c) => districtCodeLabel(String(c))).join('、');
|
||||||
|
}
|
||||||
|
|
||||||
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
|
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
|
||||||
if (!codes || codes.length < 3) return null;
|
if (!codes || codes.length < 3) return null;
|
||||||
const [provinceCode, cityCode, districtCode] = codes;
|
const [provinceCode, cityCode, districtCode] = codes;
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export const ORDER_STATUS_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
export const STORE_STATUS_LABELS: Record<string, string> = {
|
export const STORE_STATUS_LABELS: Record<string, string> = {
|
||||||
OPEN: '营业中',
|
OPEN: '营业中',
|
||||||
PAUSED: '暂停',
|
PAUSED: '临时闭店',
|
||||||
CLOSED: '已关闭',
|
CLOSED: '永久关闭',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
|
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -62,9 +62,11 @@ export const CITY_STATUS_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
|
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: '草稿',
|
PENDING_REVIEW: '待审核',
|
||||||
CONFIRMED: '已确认',
|
AWAITING_CONFIRM: '待合伙人确认',
|
||||||
PAID: '已结算',
|
UNPAID: '未打款',
|
||||||
|
PAID: '已打款',
|
||||||
|
REJECTED: '已驳回',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MEDIA_TYPE_LABELS: Record<string, string> = {
|
export const MEDIA_TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -126,3 +128,18 @@ export const LEDGER_TYPE_LABELS: Record<string, string> = {
|
|||||||
export function fmtTime(v?: string | null) {
|
export function fmtTime(v?: string | null) {
|
||||||
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手机号脱敏展示:138****5678;空值返回 — */
|
||||||
|
export function maskPhone(phone?: string | null): string {
|
||||||
|
const raw = String(phone ?? '').trim();
|
||||||
|
if (!raw) return '—';
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
if (digits.length >= 11) {
|
||||||
|
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||||
|
}
|
||||||
|
if (digits.length >= 7) {
|
||||||
|
return `${digits.slice(0, 3)}****${digits.slice(-2)}`;
|
||||||
|
}
|
||||||
|
if (digits.length > 0) return `${digits.slice(0, 1)}****`;
|
||||||
|
return '****';
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||||
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
||||||
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
||||||
|
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
|
||||||
|
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
||||||
|
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
||||||
|
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
|
||||||
|
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
|
||||||
{ value: 'PRODUCT_CREATE', label: '新增商品' },
|
{ value: 'PRODUCT_CREATE', label: '新增商品' },
|
||||||
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
|
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
|
||||||
{ value: 'PRODUCT_DELETE', label: '删除商品' },
|
{ value: 'PRODUCT_DELETE', label: '删除商品' },
|
||||||
@@ -34,11 +39,27 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
|
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
|
||||||
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
||||||
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
||||||
|
{ value: 'TICKET_CREATE', label: '创建工单' },
|
||||||
|
{ value: 'SUPPORT_TICKET_CREATE', label: '创建技术支持工单' },
|
||||||
|
{ value: 'SUPPORT_TICKET_APPROVE', label: '技术支持评审通过' },
|
||||||
|
{ value: 'SUPPORT_TICKET_REJECT', label: '技术支持评审驳回' },
|
||||||
|
{ value: 'SUPPORT_TICKET_START_TESTING', label: '技术支持转入测试' },
|
||||||
|
{ value: 'SUPPORT_TICKET_PASS', label: '技术支持测试通过' },
|
||||||
|
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
|
||||||
|
{ value: 'INVOICE_ISSUE', label: '开具发票' },
|
||||||
|
{ value: 'INVOICE_REJECT', label: '驳回发票' },
|
||||||
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
|
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
|
||||||
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
|
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
|
||||||
|
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
|
||||||
|
{ value: 'STORE_BILL_BATCH_CONFIRM', label: '批量门店对账单打款' },
|
||||||
{ value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' },
|
{ value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' },
|
||||||
|
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
|
||||||
|
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
|
||||||
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
|
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
|
||||||
{ value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' },
|
{ value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' },
|
||||||
|
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
|
||||||
|
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
|
||||||
|
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
|
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ export type StoreCreateForm = {
|
|||||||
city?: string;
|
city?: string;
|
||||||
district: string;
|
district: string;
|
||||||
districtCode?: string;
|
districtCode?: string;
|
||||||
|
categoryParentId?: string;
|
||||||
|
categoryId: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
address: string;
|
address: string;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
|
benefitUsageRule?: string;
|
||||||
|
openTime?: string;
|
||||||
|
closeTime?: string;
|
||||||
|
openTime2?: string;
|
||||||
|
closeTime2?: string;
|
||||||
|
avgPrice?: number | null;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
contractUrl?: string;
|
contractUrl?: string;
|
||||||
@@ -21,20 +31,67 @@ export type StoreCreateForm = {
|
|||||||
|
|
||||||
const PHONE_RE = /^1\d{10}$/;
|
const PHONE_RE = /^1\d{10}$/;
|
||||||
const BANK_RE = /^\d{16,19}$/;
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
|
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
|
|
||||||
|
function timeToMinutes(hhmm: string): number {
|
||||||
|
const [h, m] = hhmm.split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateStoreCreateStep1(
|
export function validateStoreCreateStep1(
|
||||||
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
form: Pick<
|
||||||
|
StoreCreateForm,
|
||||||
|
| 'partnerAccountId'
|
||||||
|
| 'cityId'
|
||||||
|
| 'regionCodes'
|
||||||
|
| 'categoryId'
|
||||||
|
| 'name'
|
||||||
|
| 'phone'
|
||||||
|
| 'address'
|
||||||
|
| 'intro'
|
||||||
|
| 'benefitUsageRule'
|
||||||
|
| 'openTime'
|
||||||
|
| 'closeTime'
|
||||||
|
| 'openTime2'
|
||||||
|
| 'closeTime2'
|
||||||
|
| 'avgPrice'
|
||||||
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
||||||
|
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
|
||||||
if (!form.name?.trim()) return '请填写门店名称';
|
if (!form.name?.trim()) return '请填写门店名称';
|
||||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||||
if (!form.address?.trim()) return '请填写详细地址';
|
if (!form.address?.trim()) return '请填写详细地址';
|
||||||
|
|
||||||
|
const openTime = String(form.openTime || '').trim();
|
||||||
|
const closeTime = String(form.closeTime || '').trim();
|
||||||
|
if (!openTime || !closeTime) return '请填写营业时间';
|
||||||
|
if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM';
|
||||||
|
if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间';
|
||||||
|
|
||||||
|
const openTime2 = String(form.openTime2 || '').trim();
|
||||||
|
const closeTime2 = String(form.closeTime2 || '').trim();
|
||||||
|
if (openTime2 || closeTime2) {
|
||||||
|
if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间';
|
||||||
|
if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM';
|
||||||
|
if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间';
|
||||||
|
if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.avgPrice != null && String(form.avgPrice).trim() !== '') {
|
||||||
|
const n = Number(form.avgPrice);
|
||||||
|
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
|
||||||
|
}
|
||||||
|
|
||||||
if (form.intro?.trim()) {
|
if (form.intro?.trim()) {
|
||||||
const len = form.intro.trim().length;
|
const len = form.intro.trim().length;
|
||||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
|
||||||
|
}
|
||||||
|
if (form.benefitUsageRule?.trim() && form.benefitUsageRule.trim().length > 1000) {
|
||||||
|
return '好客权益券使用规则最多 1000 字';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type TencentPickedLocation = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
address?: string;
|
||||||
|
name?: string;
|
||||||
|
cityname?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LbsPlaceItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
address: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
city?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
|
||||||
|
return {
|
||||||
|
latitude: item.latitude,
|
||||||
|
longitude: item.longitude,
|
||||||
|
address: item.address || undefined,
|
||||||
|
name: item.title || undefined,
|
||||||
|
cityname: item.city || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,6 +33,34 @@ type Row = {
|
|||||||
order?: { orderNo: string } | null;
|
order?: { orderNo: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CouponRedeemRecord = {
|
||||||
|
id: string;
|
||||||
|
redeemNo: string;
|
||||||
|
amount: number;
|
||||||
|
settleAmount: number;
|
||||||
|
couponAmount?: number;
|
||||||
|
role?: 'PRIMARY' | 'SECONDARY';
|
||||||
|
createdAt: string;
|
||||||
|
store?: { id: string; name: string; cityName?: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CouponRedeemSummary = {
|
||||||
|
couponNo: string;
|
||||||
|
totalAmount: number;
|
||||||
|
usedAmount: number;
|
||||||
|
balance: number;
|
||||||
|
status: string;
|
||||||
|
redeemCount: number;
|
||||||
|
redeemRecordSum: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CouponDetail = Row & {
|
||||||
|
redeemSummary?: CouponRedeemSummary | null;
|
||||||
|
redeemRecords?: CouponRedeemRecord[];
|
||||||
|
user?: { userNo?: string; phone?: string | null };
|
||||||
|
order?: { orderNo?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function BenefitCouponsPage() {
|
export default function BenefitCouponsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
||||||
@@ -50,8 +78,25 @@ export default function BenefitCouponsPage() {
|
|||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<CouponDetail | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||||
|
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
async function openRedeemDetail(redeemId: string) {
|
||||||
|
setRedeemDetailLoading(true);
|
||||||
|
setRedeemDrawerOpen(true);
|
||||||
|
try {
|
||||||
|
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
|
||||||
|
setRedeemDetail(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载核销详情失败');
|
||||||
|
setRedeemDrawerOpen(false);
|
||||||
|
} finally {
|
||||||
|
setRedeemDetailLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
||||||
@@ -197,7 +242,7 @@ export default function BenefitCouponsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title="权益券详情"
|
title="权益券详情"
|
||||||
width={560}
|
width={720}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
@@ -218,25 +263,202 @@ export default function BenefitCouponsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
||||||
|
权益核销
|
||||||
|
</Typography.Title>
|
||||||
|
{detail.redeemSummary ? (
|
||||||
|
<>
|
||||||
|
<Descriptions column={2} bordered size="small" style={{ marginBottom: 12 }}>
|
||||||
|
<Descriptions.Item label="权益券号">{detail.redeemSummary.couponNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="券状态">{detail.redeemSummary.status}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="权益总额">
|
||||||
|
¥{Number(detail.redeemSummary.totalAmount).toFixed(2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="已核销">
|
||||||
|
<Typography.Text type="danger" strong>
|
||||||
|
¥{Number(detail.redeemSummary.usedAmount).toFixed(2)}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||||
|
({detail.redeemSummary.redeemCount} 笔核销单)
|
||||||
|
</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="剩余余额">
|
||||||
|
¥{Number(detail.redeemSummary.balance).toFixed(2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销单合计额">
|
||||||
|
¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)}
|
||||||
|
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||||
|
(本券分摊合计)
|
||||||
|
</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
locale={{ emptyText: '暂无关联核销单' }}
|
||||||
|
dataSource={detail.redeemRecords ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
width: 70,
|
||||||
|
render: (v: string | undefined) =>
|
||||||
|
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '门店',
|
||||||
|
dataIndex: ['store', 'name'],
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string | undefined, row: CouponRedeemRecord) =>
|
||||||
|
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '本券分摊',
|
||||||
|
dataIndex: 'couponAmount',
|
||||||
|
width: 95,
|
||||||
|
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
||||||
|
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '核销总额',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算额',
|
||||||
|
dataIndex: 'settleAmount',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 150,
|
||||||
|
render: (v: string) => fmtTime(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 70,
|
||||||
|
render: (_: unknown, row: CouponRedeemRecord) => (
|
||||||
|
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无核销摘要</Typography.Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="核销单详情"
|
||||||
|
width={520}
|
||||||
|
open={redeemDrawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setRedeemDrawerOpen(false);
|
||||||
|
setRedeemDetail(null);
|
||||||
|
}}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{redeemDetailLoading ? (
|
||||||
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
|
) : redeemDetail ? (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="券号">{String(detail.couponNo)}</Descriptions.Item>
|
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户">
|
<Descriptions.Item label="核销额">
|
||||||
{String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机号">
|
<Descriptions.Item label="结算额">
|
||||||
{String((detail.user as { phone?: string } | undefined)?.phone ?? '—')}
|
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户">
|
||||||
|
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
||||||
|
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
|
||||||
|
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
|
||||||
|
: ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店">
|
||||||
|
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
|
||||||
|
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
|
||||||
|
? `(${(redeemDetail.store as { cityName?: string }).cityName})`
|
||||||
|
: ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店地址">
|
||||||
|
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="合伙人">
|
||||||
|
{String(
|
||||||
|
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
|
||||||
|
?.partnerAccount?.companyName ?? '—',
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="权益券号">
|
||||||
|
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联订单">
|
<Descriptions.Item label="关联订单">
|
||||||
{String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')}
|
{String(
|
||||||
|
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
|
||||||
|
'—',
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item>
|
{Array.isArray(redeemDetail.allocations) &&
|
||||||
<Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item>
|
(redeemDetail.allocations as unknown[]).length > 0 ? (
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="券分摊">
|
||||||
{COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
{(
|
||||||
</Descriptions.Item>
|
redeemDetail.allocations as Array<{
|
||||||
<Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item>
|
couponNo?: string;
|
||||||
|
orderNo?: string | null;
|
||||||
|
amount?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
}>
|
||||||
|
)
|
||||||
|
.map((a, idx) => {
|
||||||
|
const role = idx === 0 ? '主' : '次';
|
||||||
|
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||||
|
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||||
|
}`;
|
||||||
|
})
|
||||||
|
.join(';')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{redeemDetail.payout ? (
|
||||||
|
<Descriptions.Item label="门店结算单">
|
||||||
|
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||||
|
{' / '}
|
||||||
|
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{redeemDetail.rating ? (
|
||||||
|
<Descriptions.Item label="评价">
|
||||||
|
服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||||
|
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
|
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
|
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
@@ -50,6 +51,40 @@ type Row = {
|
|||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
|
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
|
||||||
|
|
||||||
|
type CityDeletePreview = {
|
||||||
|
city: { id: string; code: string; name: string; province: string; status: string };
|
||||||
|
canDelete: boolean;
|
||||||
|
blockers: string[];
|
||||||
|
warnings: string[];
|
||||||
|
summary: {
|
||||||
|
primaryPartnerCount: number;
|
||||||
|
staffCount: number;
|
||||||
|
storeCount: number;
|
||||||
|
warehouseCount: number;
|
||||||
|
orderCount: number;
|
||||||
|
redeemCount: number;
|
||||||
|
partnerBillCount: number;
|
||||||
|
};
|
||||||
|
partners: Array<{
|
||||||
|
id: string;
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
status: string;
|
||||||
|
staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>;
|
||||||
|
}>;
|
||||||
|
orphanStaff: Array<{ id: string; phone: string; name: string }>;
|
||||||
|
stores: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
status: string;
|
||||||
|
address: string;
|
||||||
|
partnerAccount?: { companyName?: string | null; phone?: string } | null;
|
||||||
|
}>;
|
||||||
|
warehouses: Array<{ id: string; name: string; status: string; address: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
|
|
||||||
export default function CitiesPage() {
|
export default function CitiesPage() {
|
||||||
@@ -82,6 +117,18 @@ export default function CitiesPage() {
|
|||||||
const createRegionCodes = Form.useWatch('regionCodes', createForm);
|
const createRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||||
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||||
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Row | null>(null);
|
||||||
|
const [deletePreview, setDeletePreview] = useState<CityDeletePreview | null>(null);
|
||||||
|
const [confirmName, setConfirmName] = useState('');
|
||||||
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
|
const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadPartners = useCallback(async (cityId: string) => {
|
const loadPartners = useCallback(async (cityId: string) => {
|
||||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
|
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
|
||||||
@@ -129,22 +176,85 @@ export default function CitiesPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDelete = async (row: Row) => {
|
||||||
|
setDeleteTarget(row);
|
||||||
|
setDeletePreview(null);
|
||||||
|
setConfirmName('');
|
||||||
|
setDeleteOpen(true);
|
||||||
|
setDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
const preview = await request<CityDeletePreview>(`/admin/cities/${row.id}/delete-preview`);
|
||||||
|
setDeletePreview(preview);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载删除预览失败');
|
||||||
|
setDeleteOpen(false);
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget || !deletePreview) return;
|
||||||
|
if (!deletePreview.canDelete) {
|
||||||
|
message.error(deletePreview.blockers.join(';') || '当前城市不可删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirmName.trim() !== deletePreview.city.name) {
|
||||||
|
message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteSubmitting(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/cities/${deleteTarget.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ confirmName: confirmName.trim() }),
|
||||||
|
});
|
||||||
|
message.success(`已删除城市「${deletePreview.city.name}」`);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeletePreview(null);
|
||||||
|
setConfirmName('');
|
||||||
|
if (detail?.id === deleteTarget.id) setDrawerOpen(false);
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
} finally {
|
||||||
|
setDeleteSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
||||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
{
|
||||||
|
title: '门店',
|
||||||
|
dataIndex: 'storeCount',
|
||||||
|
width: 70,
|
||||||
|
render: (n: number, row) => (
|
||||||
|
<Link to={`/stores?cityId=${row.id}`} title={`查看「${row.name}」门店`}>
|
||||||
|
{n ?? 0}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
<Space size={0}>
|
||||||
管理
|
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||||
</Button>
|
管理
|
||||||
|
</Button>
|
||||||
|
{canDeleteCities ? (
|
||||||
|
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -264,6 +374,26 @@ export default function CitiesPage() {
|
|||||||
setDetail(refreshed);
|
setDetail(refreshed);
|
||||||
void reload();
|
void reload();
|
||||||
}}>保存</Button>
|
}}>保存</Button>
|
||||||
|
{canDeleteCities ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
onClick={() =>
|
||||||
|
void openDelete({
|
||||||
|
id: String(detail.id),
|
||||||
|
code: String(detail.code),
|
||||||
|
name: String(detail.name),
|
||||||
|
province: String(detail.province),
|
||||||
|
status: String(detail.status),
|
||||||
|
storeCount: Number(detail.storeCount ?? 0),
|
||||||
|
orderCount: Number(detail.orderCount ?? 0),
|
||||||
|
createdAt: String(detail.createdAt ?? ''),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
删除城市
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -274,6 +404,7 @@ export default function CitiesPage() {
|
|||||||
children: detail?.id ? (
|
children: detail?.id ? (
|
||||||
<CityPartnersPanel
|
<CityPartnersPanel
|
||||||
cityId={String(detail.id)}
|
cityId={String(detail.id)}
|
||||||
|
cityCode={detail.code != null ? String(detail.code) : undefined}
|
||||||
maxPartnerCommissionRate={
|
maxPartnerCommissionRate={
|
||||||
detail.maxPartnerCommissionRate != null
|
detail.maxPartnerCommissionRate != null
|
||||||
? Number(detail.maxPartnerCommissionRate)
|
? Number(detail.maxPartnerCommissionRate)
|
||||||
@@ -406,6 +537,140 @@ export default function CitiesPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={deleteTarget ? `删除城市「${deleteTarget.name}」` : '删除城市'}
|
||||||
|
open={deleteOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
if (deleteSubmitting) return;
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
okText="确认删除"
|
||||||
|
okButtonProps={{
|
||||||
|
danger: true,
|
||||||
|
disabled:
|
||||||
|
!deletePreview?.canDelete ||
|
||||||
|
!deletePreview ||
|
||||||
|
confirmName.trim() !== (deletePreview?.city.name ?? ''),
|
||||||
|
loading: deleteSubmitting,
|
||||||
|
}}
|
||||||
|
confirmLoading={deleteSubmitting}
|
||||||
|
onOk={() => void confirmDelete()}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{deleteLoading || !deletePreview ? (
|
||||||
|
<Typography.Text type="secondary">正在加载关联数据…</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<Descriptions size="small" bordered column={2}>
|
||||||
|
<Descriptions.Item label="编码">{deletePreview.city.code}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="省份">{deletePreview.city.province}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="合伙人主账号">{deletePreview.summary.primaryPartnerCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="子账号">{deletePreview.summary.staffCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店">{deletePreview.summary.storeCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="仓库">{deletePreview.summary.warehouseCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单">{deletePreview.summary.orderCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销">{deletePreview.summary.redeemCount}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
{deletePreview.blockers.length > 0 && (
|
||||||
|
<Typography.Paragraph type="danger" style={{ marginBottom: 0 }}>
|
||||||
|
{deletePreview.blockers.map((b) => (
|
||||||
|
<div key={b}>• {b}</div>
|
||||||
|
))}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
{deletePreview.warnings.length > 0 && (
|
||||||
|
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
|
||||||
|
{deletePreview.warnings.map((w) => (
|
||||||
|
<div key={w}>• {w}</div>
|
||||||
|
))}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>合伙人及子账号</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
|
locale={{ emptyText: '无合伙人' }}
|
||||||
|
dataSource={deletePreview.partners.flatMap((p) => [
|
||||||
|
{
|
||||||
|
id: p.id,
|
||||||
|
kind: '主账号',
|
||||||
|
name: p.companyName || p.name,
|
||||||
|
phone: p.phone,
|
||||||
|
parent: '—',
|
||||||
|
},
|
||||||
|
...p.staff.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
kind: '子账号',
|
||||||
|
name: s.name,
|
||||||
|
phone: s.phone,
|
||||||
|
parent: p.companyName || p.name,
|
||||||
|
})),
|
||||||
|
]).concat(
|
||||||
|
deletePreview.orphanStaff.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
kind: '子账号',
|
||||||
|
name: s.name,
|
||||||
|
phone: s.phone,
|
||||||
|
parent: '(无主账号)',
|
||||||
|
})),
|
||||||
|
)}
|
||||||
|
columns={[
|
||||||
|
{ title: '类型', dataIndex: 'kind', width: 80 },
|
||||||
|
{ title: '名称', dataIndex: 'name', ellipsis: true },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||||
|
{ title: '归属', dataIndex: 'parent', ellipsis: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>门店</Typography.Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
|
locale={{ emptyText: '无门店' }}
|
||||||
|
dataSource={deletePreview.stores}
|
||||||
|
columns={[
|
||||||
|
{ title: '门店名', dataIndex: 'name', ellipsis: true },
|
||||||
|
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '合伙人',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deletePreview.canDelete ? (
|
||||||
|
<Form.Item
|
||||||
|
label={`请输入城市名称「${deletePreview.city.name}」确认删除`}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={confirmName}
|
||||||
|
placeholder={deletePreview.city.name}
|
||||||
|
onChange={(e) => setConfirmName(e.target.value)}
|
||||||
|
disabled={deleteSubmitting}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">存在阻断项,无法删除。请先处理订单等关联数据。</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Cascader,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -32,9 +31,10 @@ import {
|
|||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||||
|
|
||||||
type SubRow = PartnerSubAccountRow;
|
type SubRow = PartnerSubAccountRow;
|
||||||
@@ -48,6 +48,7 @@ type Row = {
|
|||||||
cityId?: string | null;
|
cityId?: string | null;
|
||||||
cityName?: string | null;
|
cityName?: string | null;
|
||||||
scopeType?: string;
|
scopeType?: string;
|
||||||
|
districtCodes?: string[] | null;
|
||||||
orderCommissionRate?: number;
|
orderCommissionRate?: number;
|
||||||
redeemCommissionRate?: number;
|
redeemCommissionRate?: number;
|
||||||
bindingStatus?: string;
|
bindingStatus?: string;
|
||||||
@@ -98,13 +99,25 @@ function flattenDistrictCodes(values: string[] | string[][] | undefined): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
|
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
|
||||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||||
if (sum > maxRate + 1e-9) {
|
const max = Number(maxRate);
|
||||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||||
|
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||||
|
if (sum > max + 1e-9) {
|
||||||
|
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatApiError(err: unknown): string | null {
|
||||||
|
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||||
|
if (!(err instanceof Error)) return '操作失败';
|
||||||
|
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||||
|
const label = districtCodeLabel(code);
|
||||||
|
return label !== code ? `${label}(${code})` : code;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function CityPartnersPage() {
|
export default function CityPartnersPage() {
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
@@ -135,6 +148,7 @@ export default function CityPartnersPage() {
|
|||||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||||
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
|
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
|
||||||
const [createMaxRate, setCreateMaxRate] = useState(0.05);
|
const [createMaxRate, setCreateMaxRate] = useState(0.05);
|
||||||
|
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
|
||||||
|
|
||||||
const loadCities = useCallback(async () => {
|
const loadCities = useCallback(async () => {
|
||||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
@@ -145,11 +159,15 @@ export default function CityPartnersPage() {
|
|||||||
void loadCities();
|
void loadCities();
|
||||||
}, [loadCities]);
|
}, [loadCities]);
|
||||||
|
|
||||||
|
const editCityCode = detail?.cityId
|
||||||
|
? cities.find((c) => c.id === detail.cityId)?.code
|
||||||
|
: undefined;
|
||||||
|
|
||||||
async function openPartner(id: string) {
|
async function openPartner(id: string) {
|
||||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||||
setDetail(d);
|
setDetail(d);
|
||||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||||
setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05);
|
setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05));
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
phone: d.phone,
|
phone: d.phone,
|
||||||
@@ -171,28 +189,33 @@ export default function CityPartnersPage() {
|
|||||||
|
|
||||||
async function savePartner() {
|
async function savePartner() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const v = await editForm.validateFields();
|
try {
|
||||||
const err = commissionSumError(
|
const v = await editForm.validateFields();
|
||||||
Number(v.orderCommissionRate ?? 0),
|
const err = commissionSumError(
|
||||||
Number(v.redeemCommissionRate ?? 0),
|
Number(v.orderCommissionRate ?? 0),
|
||||||
maxCommissionRate,
|
Number(v.redeemCommissionRate ?? 0),
|
||||||
);
|
maxCommissionRate,
|
||||||
if (err) {
|
);
|
||||||
message.error(err);
|
if (err) {
|
||||||
return;
|
message.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await request(`/admin/partners/${detail.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...v,
|
||||||
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
}
|
}
|
||||||
await request(`/admin/partners/${detail.id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({
|
|
||||||
...v,
|
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
|
||||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
message.success('已保存');
|
|
||||||
setDrawerOpen(false);
|
|
||||||
void reload();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPartnerContext(parentId: string) {
|
async function refreshPartnerContext(parentId: string) {
|
||||||
@@ -224,18 +247,29 @@ export default function CityPartnersPage() {
|
|||||||
async function openAddSubAccount(parentId: string) {
|
async function openAddSubAccount(parentId: string) {
|
||||||
await openPartner(parentId);
|
await openPartner(parentId);
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCreateCityChange(cityId: string) {
|
async function onCreateCityChange(cityId: string) {
|
||||||
|
createForm.setFieldsValue({ districtCodes: undefined });
|
||||||
|
const matched = cities.find((c) => c.id === cityId);
|
||||||
|
setCreateCityCode(matched?.code);
|
||||||
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
|
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
|
||||||
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
|
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
|
||||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||||
|
{
|
||||||
|
title: '区县',
|
||||||
|
dataIndex: 'districtCodes',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
|
},
|
||||||
|
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
||||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
||||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
@@ -267,7 +301,16 @@ export default function CityPartnersPage() {
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (v) => v || '—',
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
{ title: '门店', dataIndex: 'storeCount', width: 60 },
|
{
|
||||||
|
title: '门店',
|
||||||
|
dataIndex: 'storeCount',
|
||||||
|
width: 60,
|
||||||
|
render: (n: number, row) => (
|
||||||
|
<Link to={`/stores?partnerId=${row.id}`} title={`查看「${row.companyName || row.phone}」门店`}>
|
||||||
|
{n ?? 0}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -299,6 +342,7 @@ export default function CityPartnersPage() {
|
|||||||
});
|
});
|
||||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||||
setCreateMaxRate(0.05);
|
setCreateMaxRate(0.05);
|
||||||
|
setCreateCityCode(undefined);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -327,12 +371,6 @@ export default function CityPartnersPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="companyName" label="公司">
|
|
||||||
<Input allowClear placeholder="公司名" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="phone" label="手机">
|
|
||||||
<Input allowClear placeholder="登录手机" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="cityId" label="城市">
|
<Form.Item name="cityId" label="城市">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
@@ -343,6 +381,12 @@ export default function CityPartnersPage() {
|
|||||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="companyName" label="公司">
|
||||||
|
<Input allowClear placeholder="公司名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机">
|
||||||
|
<Input allowClear placeholder="登录手机" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
@@ -409,7 +453,11 @@ export default function CityPartnersPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="门店数">
|
||||||
|
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
|
||||||
|
{detail.storeCount ?? 0}
|
||||||
|
</Link>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="管仓仓库" span={2}>
|
<Descriptions.Item label="管仓仓库" span={2}>
|
||||||
{detail.managedWarehouseName ?? (
|
{detail.managedWarehouseName ?? (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
@@ -425,8 +473,8 @@ export default function CityPartnersPage() {
|
|||||||
label: '主账号',
|
label: '主账号',
|
||||||
children: (
|
children: (
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
|
<Form.Item name="companyName" label="公司名">
|
||||||
<Input />
|
<Input placeholder="选填" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||||
<Input />
|
<Input />
|
||||||
@@ -437,8 +485,8 @@ export default function CityPartnersPage() {
|
|||||||
<Form.Item name="contactPhone" label="业务联系电话">
|
<Form.Item name="contactPhone" label="业务联系电话">
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
<Form.Item name="address" label="地址">
|
||||||
<Input />
|
<Input placeholder="选填" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||||
<Select options={BINDING_OPTIONS} />
|
<Select options={BINDING_OPTIONS} />
|
||||||
@@ -447,8 +495,12 @@ export default function CityPartnersPage() {
|
|||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
<Form.Item name="districtCodes" label="区县">
|
<Form.Item
|
||||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
name="districtCodes"
|
||||||
|
label="区县"
|
||||||
|
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
|
>
|
||||||
|
<CityDistrictMultiSelect cityCode={editCityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
<Space style={{ width: '100%' }} size="large">
|
<Space style={{ width: '100%' }} size="large">
|
||||||
@@ -482,7 +534,7 @@ export default function CityPartnersPage() {
|
|||||||
subs={detail.children ?? []}
|
subs={detail.children ?? []}
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}}
|
}}
|
||||||
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
||||||
@@ -502,29 +554,34 @@ export default function CityPartnersPage() {
|
|||||||
width={560}
|
width={560}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
onOk={async () => {
|
onOk={async () => {
|
||||||
const v = await createForm.validateFields();
|
try {
|
||||||
const err = commissionSumError(
|
const v = await createForm.validateFields();
|
||||||
Number(v.orderCommissionRate ?? 0),
|
const err = commissionSumError(
|
||||||
Number(v.redeemCommissionRate ?? 3),
|
Number(v.orderCommissionRate ?? 0),
|
||||||
createMaxRate,
|
Number(v.redeemCommissionRate ?? 3),
|
||||||
);
|
createMaxRate,
|
||||||
if (err) {
|
);
|
||||||
message.error(err);
|
if (err) {
|
||||||
return;
|
message.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await request('/admin/partners', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...v,
|
||||||
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
|
districtCodes:
|
||||||
|
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已创建');
|
||||||
|
setCreateOpen(false);
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
}
|
}
|
||||||
await request('/admin/partners', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
...v,
|
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
|
||||||
districtCodes:
|
|
||||||
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
message.success('已创建');
|
|
||||||
setCreateOpen(false);
|
|
||||||
void reload();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form form={createForm} layout="vertical">
|
<Form form={createForm} layout="vertical">
|
||||||
@@ -536,8 +593,8 @@ export default function CityPartnersPage() {
|
|||||||
onChange={(id) => void onCreateCityChange(id)}
|
onChange={(id) => void onCreateCityChange(id)}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
|
<Form.Item name="companyName" label="公司名">
|
||||||
<Input />
|
<Input placeholder="选填" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||||
<Input />
|
<Input />
|
||||||
@@ -545,15 +602,19 @@ export default function CityPartnersPage() {
|
|||||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
|
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
<Form.Item name="address" label="地址">
|
||||||
<Input />
|
<Input placeholder="选填" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
name="districtCodes"
|
||||||
|
label="区县"
|
||||||
|
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
|
>
|
||||||
|
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
<Space style={{ width: '100%' }} size="large">
|
<Space style={{ width: '100%' }} size="large">
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -17,10 +19,13 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
|
WAREHOUSE_FULFILLMENT_MODE_LABELS,
|
||||||
WAREHOUSE_MANAGER_LABELS,
|
WAREHOUSE_MANAGER_LABELS,
|
||||||
WAREHOUSE_STATUS_LABELS,
|
WAREHOUSE_STATUS_LABELS,
|
||||||
|
WarehouseFulfillmentMode,
|
||||||
WarehouseManagerType,
|
WarehouseManagerType,
|
||||||
WarehouseStatus,
|
WarehouseStatus,
|
||||||
|
type FulfillmentProviderDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
@@ -39,6 +44,13 @@ type Row = {
|
|||||||
partnerAccountId: string | null;
|
partnerAccountId: string | null;
|
||||||
partnerCompanyName?: string | null;
|
partnerCompanyName?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
fulfillmentMode?: string;
|
||||||
|
fulfillmentProviderId?: string | null;
|
||||||
|
fulfillmentProviderName?: string | null;
|
||||||
|
manualCarrierLabel?: string | null;
|
||||||
|
manualQueryUrlTemplate?: string | null;
|
||||||
|
lng?: number | null;
|
||||||
|
lat?: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,6 +60,79 @@ type PartnerOption = { id: string; companyName: string };
|
|||||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
|
|
||||||
|
function FulfillmentFields({
|
||||||
|
mode,
|
||||||
|
providerOptions,
|
||||||
|
onModeChange,
|
||||||
|
}: {
|
||||||
|
mode: WarehouseFulfillmentMode;
|
||||||
|
providerOptions: FulfillmentProviderDto[];
|
||||||
|
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
||||||
|
}) {
|
||||||
|
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name="fulfillmentMode"
|
||||||
|
label="支付后自动发货"
|
||||||
|
extra={
|
||||||
|
autoShip
|
||||||
|
? '同城订单支付成功后,自动推送仓配承运商(如小飞侠)并进入配送中'
|
||||||
|
: '支付后仅分配仓库,保持待发货,由仓管或总部手工填运单'
|
||||||
|
}
|
||||||
|
getValueProps={(v) => ({ checked: v === WarehouseFulfillmentMode.API_AUTO })}
|
||||||
|
getValueFromEvent={(checked: boolean) =>
|
||||||
|
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="开"
|
||||||
|
unCheckedChildren="关"
|
||||||
|
onChange={(checked) => {
|
||||||
|
onModeChange(
|
||||||
|
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{autoShip && (
|
||||||
|
<Form.Item
|
||||||
|
name="fulfillmentProviderId"
|
||||||
|
label="仓配承运商"
|
||||||
|
rules={[{ required: true, message: '自动发货须选择承运商' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
|
||||||
|
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{!autoShip && (
|
||||||
|
<>
|
||||||
|
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
|
||||||
|
<Input placeholder="如 顺丰速运" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="manualQueryUrlTemplate"
|
||||||
|
label="物流查询链接模板"
|
||||||
|
extra="可用 {trackingNo} 占位符"
|
||||||
|
>
|
||||||
|
<Input placeholder="https://example.com/track?no={trackingNo}" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="lng" label="经度">
|
||||||
|
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="lat" label="纬度">
|
||||||
|
<InputNumber step={0.001} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CityWarehousesPage() {
|
export default function CityWarehousesPage() {
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
@@ -73,6 +158,13 @@ export default function CityWarehousesPage() {
|
|||||||
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||||
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||||
|
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||||||
|
WarehouseFulfillmentMode.MANUAL,
|
||||||
|
);
|
||||||
|
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||||||
|
WarehouseFulfillmentMode.MANUAL,
|
||||||
|
);
|
||||||
|
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
|
||||||
|
|
||||||
const loadCities = useCallback(async () => {
|
const loadCities = useCallback(async () => {
|
||||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
@@ -88,6 +180,9 @@ export default function CityWarehousesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadCities();
|
void loadCities();
|
||||||
|
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
|
||||||
|
.then(setProviderOptions)
|
||||||
|
.catch(() => {});
|
||||||
}, [loadCities]);
|
}, [loadCities]);
|
||||||
|
|
||||||
async function openEdit(row: Row) {
|
async function openEdit(row: Row) {
|
||||||
@@ -102,23 +197,29 @@ export default function CityWarehousesPage() {
|
|||||||
managerType: row.managerType,
|
managerType: row.managerType,
|
||||||
partnerAccountId: row.partnerAccountId,
|
partnerAccountId: row.partnerAccountId,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
|
||||||
|
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
|
||||||
|
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
|
||||||
|
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
|
||||||
|
lng: row.lng ?? undefined,
|
||||||
|
lat: row.lat ?? undefined,
|
||||||
});
|
});
|
||||||
|
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onManagerTypeChange(
|
function onManagerTypeChange(
|
||||||
type: WarehouseManagerType,
|
v: WarehouseManagerType,
|
||||||
form: typeof createForm | typeof editForm,
|
form: typeof createForm,
|
||||||
setType: (v: WarehouseManagerType) => void,
|
setType: (t: WarehouseManagerType) => void,
|
||||||
) {
|
) {
|
||||||
setType(type);
|
setType(v);
|
||||||
if (type !== WarehouseManagerType.PARTNER) {
|
if (v !== WarehouseManagerType.PARTNER) {
|
||||||
form.setFieldValue('partnerAccountId', undefined);
|
form.setFieldValue('partnerAccountId', undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
|
|
||||||
{
|
{
|
||||||
title: '城市',
|
title: '城市',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -128,17 +229,17 @@ export default function CityWarehousesPage() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
|
||||||
|
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
|
||||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '管仓类型',
|
title: '管仓',
|
||||||
dataIndex: 'managerType',
|
width: 90,
|
||||||
width: 100,
|
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
|
||||||
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '管仓合伙人',
|
title: '合伙人',
|
||||||
dataIndex: 'partnerCompanyName',
|
dataIndex: 'partnerCompanyName',
|
||||||
width: 120,
|
width: 120,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
@@ -149,6 +250,16 @@ export default function CityWarehousesPage() {
|
|||||||
'—'
|
'—'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '自动发货',
|
||||||
|
width: 130,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.fulfillmentMode === 'API_AUTO' ? (
|
||||||
|
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -190,21 +301,23 @@ export default function CityWarehousesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
||||||
仓库管理
|
|
||||||
</Typography.Title>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
|
|
||||||
setCreateManagerType(WarehouseManagerType.HQ);
|
setCreateManagerType(WarehouseManagerType.HQ);
|
||||||
setCreateCityId(undefined);
|
setCreateCityId(undefined);
|
||||||
setPartners([]);
|
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
||||||
|
createForm.setFieldsValue({
|
||||||
|
managerType: WarehouseManagerType.HQ,
|
||||||
|
status: WarehouseStatus.ACTIVE,
|
||||||
|
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
||||||
|
});
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
新建仓库
|
新增仓库
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -212,7 +325,7 @@ export default function CityWarehousesPage() {
|
|||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
|
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
@@ -225,7 +338,7 @@ export default function CityWarehousesPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="name" label="仓库名">
|
<Form.Item name="name" label="仓库名">
|
||||||
<Input allowClear placeholder="仓库名" />
|
<Input allowClear />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="cityId" label="城市">
|
<Form.Item name="cityId" label="城市">
|
||||||
<Select
|
<Select
|
||||||
@@ -233,47 +346,33 @@ export default function CityWarehousesPage() {
|
|||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
style={{ width: 140 }}
|
style={{ width: 140 }}
|
||||||
placeholder="全部城市"
|
|
||||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="managerType" label="管仓类型">
|
<Form.Item name="managerType" label="管仓">
|
||||||
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} />
|
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 100 }} placeholder="全部" options={STATUS_OPTIONS} />
|
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Button type="primary" htmlType="submit">
|
||||||
<Button type="primary" htmlType="submit">
|
查询
|
||||||
查询
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
filterForm.resetFields();
|
|
||||||
setFilters({});
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
className="admin-table-nowrap"
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1400 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
total: data?.total ?? 0,
|
total: data?.total ?? 0,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
|
||||||
onChange: (p, ps) => {
|
onChange: (p, ps) => {
|
||||||
setPage(p);
|
setPage(p);
|
||||||
setPageSize(ps);
|
setPageSize(ps);
|
||||||
@@ -282,7 +381,7 @@ export default function CityWarehousesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="新建仓库"
|
title="新增仓库"
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
width={520}
|
width={520}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
@@ -341,6 +440,11 @@ export default function CityWarehousesPage() {
|
|||||||
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Select options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<FulfillmentFields
|
||||||
|
mode={createFulfillmentMode}
|
||||||
|
providerOptions={providerOptions}
|
||||||
|
onModeChange={setCreateFulfillmentMode}
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -399,6 +503,11 @@ export default function CityWarehousesPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Select options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<FulfillmentFields
|
||||||
|
mode={editFulfillmentMode}
|
||||||
|
providerOptions={providerOptions}
|
||||||
|
onModeChange={setEditFulfillmentMode}
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
|
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -51,8 +52,18 @@ export default function DashboardPage() {
|
|||||||
request<DashboardStats>('/admin/dashboard/stats')
|
request<DashboardStats>('/admin/dashboard/stats')
|
||||||
.then(setStats)
|
.then(setStats)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
void loadVersion();
|
request<HqProfile>('/admin/auth/me')
|
||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
.then((p) => {
|
||||||
|
setProfile(p);
|
||||||
|
if (p.adminRole === 'SUPER_ADMIN') {
|
||||||
|
void loadVersion();
|
||||||
|
} else {
|
||||||
|
setVersionLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setVersionLoading(false);
|
||||||
|
});
|
||||||
}, [loadVersion]);
|
}, [loadVersion]);
|
||||||
|
|
||||||
function handleDeploy() {
|
function handleDeploy() {
|
||||||
@@ -82,6 +93,7 @@ export default function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
<Typography.Title level={4}>数据概览</Typography.Title>
|
||||||
|
|
||||||
|
{isSuperAdmin ? (
|
||||||
<Card
|
<Card
|
||||||
title="系统版本"
|
title="系统版本"
|
||||||
loading={versionLoading}
|
loading={versionLoading}
|
||||||
@@ -91,16 +103,14 @@ export default function DashboardPage() {
|
|||||||
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
|
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
|
||||||
刷新版本
|
刷新版本
|
||||||
</Button>
|
</Button>
|
||||||
{isSuperAdmin && (
|
<Button
|
||||||
<Button
|
type="primary"
|
||||||
type="primary"
|
icon={<CloudUploadOutlined />}
|
||||||
icon={<CloudUploadOutlined />}
|
loading={deploying}
|
||||||
loading={deploying}
|
onClick={handleDeploy}
|
||||||
onClick={handleDeploy}
|
>
|
||||||
>
|
发布更新
|
||||||
发布更新
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -121,6 +131,7 @@ export default function DashboardPage() {
|
|||||||
<Typography.Text type="secondary">尚未记录发版信息</Typography.Text>
|
<Typography.Text type="secondary">尚未记录发版信息</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
@@ -164,6 +175,47 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card
|
||||||
|
loading={loading}
|
||||||
|
title="待审核合伙人打款"
|
||||||
|
extra={<Link to="/finance/partner-bills">去处理</Link>}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={stats?.pendingBills ?? 0}
|
||||||
|
suffix="笔"
|
||||||
|
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
合伙人已确认申请,待总部通过或驳回
|
||||||
|
{(stats?.pendingPartnerDraftBills ?? 0) > 0
|
||||||
|
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
|
||||||
|
: ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card
|
||||||
|
loading={loading}
|
||||||
|
title="待打款门店结算"
|
||||||
|
extra={<Link to="/finance/store-bills">去处理</Link>}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={stats?.pendingPayouts ?? 0}
|
||||||
|
suffix="笔"
|
||||||
|
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card loading={loading} title="待处理工单">
|
||||||
|
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card title="已合并访客账号" loading={loading}>
|
<Card title="已合并访客账号" loading={loading}>
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
DEFAULT_XFX_LOGISTICS_PRICING,
|
||||||
|
FULFILLMENT_PROVIDER_STATUS_LABELS,
|
||||||
|
FULFILLMENT_PROVIDER_TYPE_LABELS,
|
||||||
|
FulfillmentProviderStatus,
|
||||||
|
FulfillmentProviderType,
|
||||||
|
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||||||
|
LogisticsSettlementMethod,
|
||||||
|
isXfxProviderCode,
|
||||||
|
type FulfillmentProviderDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
|
||||||
|
([value, label]) => ({ value, label }),
|
||||||
|
);
|
||||||
|
const SIGN_OPTIONS = [
|
||||||
|
{ value: 'MD5', label: 'MD5' },
|
||||||
|
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
|
||||||
|
|
||||||
|
export default function FulfillmentProvidersPage() {
|
||||||
|
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const watchedCode = Form.useWatch('code', form);
|
||||||
|
const watchedType = Form.useWatch('type', form);
|
||||||
|
|
||||||
|
const showXfxFields = useMemo(
|
||||||
|
() => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API,
|
||||||
|
[watchedCode, watchedType],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
||||||
|
setRows(res);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditRow(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({
|
||||||
|
code: 'XFX',
|
||||||
|
name: '小飞侠',
|
||||||
|
type: FulfillmentProviderType.API,
|
||||||
|
status: FulfillmentProviderStatus.ACTIVE,
|
||||||
|
apiUrl: DEFAULT_XFX_API_URL,
|
||||||
|
signType: 'MD5',
|
||||||
|
settlementMethod: LogisticsSettlementMethod.PREPAID,
|
||||||
|
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||||||
|
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||||||
|
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||||
|
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||||
|
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: FulfillmentProviderDto) {
|
||||||
|
setEditRow(row);
|
||||||
|
const xfx = row.xiaofeixiaConfig;
|
||||||
|
const pricing = row.pricingRules;
|
||||||
|
form.setFieldsValue({
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL,
|
||||||
|
mchId: xfx?.mchId || '',
|
||||||
|
apiKey: '',
|
||||||
|
signType: xfx?.signType || 'MD5',
|
||||||
|
appId: xfx?.appId || '',
|
||||||
|
bankAccountName: row.bankAccountName || '',
|
||||||
|
bankName: row.bankName || '',
|
||||||
|
bankBranch: row.bankBranch || '',
|
||||||
|
bankAccountNo: row.bankAccountNo || '',
|
||||||
|
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
|
||||||
|
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||||||
|
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||||||
|
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||||
|
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||||
|
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const v = await form.validateFields();
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: v.name,
|
||||||
|
type: v.type,
|
||||||
|
status: v.status,
|
||||||
|
bankAccountName: v.bankAccountName || null,
|
||||||
|
bankName: v.bankName || null,
|
||||||
|
bankBranch: v.bankBranch || null,
|
||||||
|
bankAccountNo: v.bankAccountNo || null,
|
||||||
|
settlementMethod: v.settlementMethod,
|
||||||
|
pricingRules: {
|
||||||
|
baseBottles: Number(v.baseBottles),
|
||||||
|
baseFee: Number(v.baseFee),
|
||||||
|
extraBottleFee: Number(v.extraBottleFee),
|
||||||
|
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||||||
|
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||||||
|
payload.xiaofeixiaConfig = {
|
||||||
|
apiUrl: v.apiUrl,
|
||||||
|
mchId: v.mchId,
|
||||||
|
apiKey: v.apiKey || undefined,
|
||||||
|
signType: v.signType,
|
||||||
|
appId: v.appId || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editRow) {
|
||||||
|
await request(`/admin/fulfillment-providers/${editRow.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
message.success('已更新');
|
||||||
|
} else {
|
||||||
|
await request('/admin/fulfillment-providers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: v.code,
|
||||||
|
...payload,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<FulfillmentProviderDto> = [
|
||||||
|
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||||
|
{ title: '名称', dataIndex: 'name' },
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算',
|
||||||
|
dataIndex: 'settlementMethod',
|
||||||
|
width: 100,
|
||||||
|
render: (v) =>
|
||||||
|
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '充值余额',
|
||||||
|
dataIndex: 'prepaidBalance',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (v) => (
|
||||||
|
<Tag color={v === 'ACTIVE' ? 'green' : 'default'}>
|
||||||
|
{FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '接口配置',
|
||||||
|
render: (_, row) => {
|
||||||
|
if (isXfxProviderCode(row.code)) {
|
||||||
|
const cfg = row.xiaofeixiaConfig;
|
||||||
|
if (!cfg?.apiUrl) return <Tag>未配置</Tag>;
|
||||||
|
return (
|
||||||
|
<span title={cfg.apiUrl}>
|
||||||
|
{cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return row.hasConfig ? '已配置' : '—';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
仓配管理
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
注册承运商
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editRow ? '编辑承运商' : '注册承运商'}
|
||||||
|
open={open}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
width={640}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
|
||||||
|
<Input disabled={Boolean(editRow)} placeholder="如 XFX、JD、SF" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 小飞侠、京东物流" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||||
|
<Select options={TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
|
<Select options={STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider orientation="left">物流对账</Divider>
|
||||||
|
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||||||
|
<Select options={SETTLEMENT_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankAccountName" label="收款户名">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankName" label="开户银行">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankBranch" label="开户支行">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankAccountNo" label="银行账号">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Space wrap style={{ width: '100%' }}>
|
||||||
|
<Form.Item
|
||||||
|
name="baseBottles"
|
||||||
|
label="起送瓶数"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="baseFee"
|
||||||
|
label="起送费用(元)"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="extraBottleFee"
|
||||||
|
label="加一瓶(元)"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
|
||||||
|
<InputNumber min={1} style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
|
||||||
|
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{showXfxFields && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="left">小飞侠接口参数</Divider>
|
||||||
|
<Form.Item
|
||||||
|
name="apiUrl"
|
||||||
|
label="API 地址"
|
||||||
|
rules={[{ required: true, message: '请填写小飞侠 API 地址' }]}
|
||||||
|
extra="推单请求将发往此地址"
|
||||||
|
>
|
||||||
|
<Input placeholder={DEFAULT_XFX_API_URL} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="mchId"
|
||||||
|
label="商户号"
|
||||||
|
rules={[{ required: true, message: '请填写商户号' }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="apiKey"
|
||||||
|
label="API Key"
|
||||||
|
rules={editRow ? [] : [{ required: true, message: '请填写 API Key' }]}
|
||||||
|
extra={
|
||||||
|
editRow?.xiaofeixiaConfig?.hasApiKey
|
||||||
|
? '已配置密钥;留空则保持不变'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={editRow ? '留空则不修改' : '请输入'} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="signType" label="签名类型" initialValue="MD5">
|
||||||
|
<Select options={SIGN_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="appId" label="AppID(可选)">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,15 @@ type AccountPermRes = {
|
|||||||
|
|
||||||
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
||||||
|
|
||||||
|
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
|
||||||
|
|
||||||
|
function groupColSpan(itemCount: number): number {
|
||||||
|
if (itemCount <= 2) return 12;
|
||||||
|
if (itemCount === 3) return 8;
|
||||||
|
if (itemCount === 4) return 6;
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
|
||||||
function PermissionChecklist({
|
function PermissionChecklist({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -49,13 +58,44 @@ function PermissionChecklist({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(checked) => onChange(checked as string[])}
|
onChange={(checked) => onChange(checked as string[])}
|
||||||
>
|
>
|
||||||
<Row gutter={[8, 8]}>
|
{CATALOG_GROUPS.map((group) => {
|
||||||
{HQ_PERMISSION_CATALOG.map((item) => (
|
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
|
||||||
<Col key={item.key} span={8}>
|
const span = groupColSpan(items.length);
|
||||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
const compact = items.length <= 3;
|
||||||
</Col>
|
return (
|
||||||
))}
|
<div
|
||||||
</Row>
|
key={group}
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#fafafa',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
|
||||||
|
{group}
|
||||||
|
</Typography.Text>
|
||||||
|
{compact ? (
|
||||||
|
<Space size={[24, 8]} wrap>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Checkbox key={item.key} value={item.key}>
|
||||||
|
{item.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Row gutter={[12, 10]}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Col key={item.key} xs={24} sm={12} md={span}>
|
||||||
|
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Checkbox.Group>
|
</Checkbox.Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,6 +114,12 @@ export default function HqPermissionsPage() {
|
|||||||
const [accountLoading, setAccountLoading] = useState(false);
|
const [accountLoading, setAccountLoading] = useState(false);
|
||||||
const [accountSaving, setAccountSaving] = useState(false);
|
const [accountSaving, setAccountSaving] = useState(false);
|
||||||
|
|
||||||
|
const selectedAccount = useMemo(
|
||||||
|
() => accounts.find((a) => a.id === accountId),
|
||||||
|
[accounts, accountId],
|
||||||
|
);
|
||||||
|
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
const previewEffectiveKeys = useMemo(
|
const previewEffectiveKeys = useMemo(
|
||||||
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
|
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
|
||||||
[roleInheritedKeys, accountKeys],
|
[roleInheritedKeys, accountKeys],
|
||||||
@@ -158,7 +204,9 @@ export default function HqPermissionsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。
|
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限。
|
||||||
|
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
||||||
|
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -183,7 +231,11 @@ export default function HqPermissionsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
{role === 'SUPER_ADMIN' ? (
|
{role === 'SUPER_ADMIN' ? (
|
||||||
<Alert type="info" showIcon message="超级管理员拥有全部权限,无需配置" />
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
|
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
|
||||||
@@ -212,12 +264,10 @@ export default function HqPermissionsPage() {
|
|||||||
value={accountId}
|
value={accountId}
|
||||||
onChange={setAccountId}
|
onChange={setAccountId}
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
options={accounts
|
options={accounts.map((a) => ({
|
||||||
.filter((a) => a.adminRole !== 'SUPER_ADMIN')
|
value: a.id,
|
||||||
.map((a) => ({
|
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
|
||||||
value: a.id,
|
}))}
|
||||||
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -225,28 +275,57 @@ export default function HqPermissionsPage() {
|
|||||||
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Space wrap style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<span>角色继承:</span>
|
<span style={{ marginRight: 8 }}>角色继承:</span>
|
||||||
{roleInheritedKeys.map((key) => {
|
{selectedIsSuperAdmin ? (
|
||||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
<Tag color="blue">超级管理员基础权限(不含危险操作)</Tag>
|
||||||
return (
|
) : (
|
||||||
<Tag key={key} color="blue">
|
<Space wrap size={[4, 4]}>
|
||||||
{item?.label || key}
|
{roleInheritedKeys.map((key) => {
|
||||||
</Tag>
|
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||||
);
|
return (
|
||||||
})}
|
<Tag key={key} color="blue">
|
||||||
</Space>
|
{item?.label || key}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!roleInheritedKeys.length ? (
|
||||||
|
<Typography.Text type="secondary">无</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。
|
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。危险操作(删除用户/订单/城市)默认不授予,需在此勾选。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
||||||
<Space wrap style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<span>合并生效:</span>
|
<span style={{ marginRight: 8 }}>合并生效:</span>
|
||||||
{previewEffectiveKeys.map((key) => {
|
{selectedIsSuperAdmin ? (
|
||||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
<Space wrap size={[4, 4]}>
|
||||||
return <Tag key={key}>{item?.label || key}</Tag>;
|
<Tag>基础权限(全部)</Tag>
|
||||||
})}
|
{accountKeys.map((key) => {
|
||||||
</Space>
|
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||||
|
return (
|
||||||
|
<Tag key={key} color="orange">
|
||||||
|
{item?.label || key}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
{previewEffectiveKeys.map((key) => {
|
||||||
|
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||||
|
return (
|
||||||
|
<Tag key={key}>
|
||||||
|
{item?.label || key}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
||||||
保存用户权限
|
保存用户权限
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Upload,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
INVOICE_KIND_LABELS,
|
||||||
|
INVOICE_STATUS_LABELS,
|
||||||
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceKind,
|
||||||
|
type InvoiceStatus,
|
||||||
|
type InvoiceTitleType,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
invoiceNo: string;
|
||||||
|
orderNo?: string;
|
||||||
|
titleType: InvoiceTitleType;
|
||||||
|
invoiceKind: InvoiceKind;
|
||||||
|
titleName: string;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
overdue?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
fileUrl?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
taxNo?: string;
|
||||||
|
addressPhone?: string;
|
||||||
|
bankAccount?: string;
|
||||||
|
payAmount?: string;
|
||||||
|
userPhone?: string;
|
||||||
|
remark?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateFormValues = {
|
||||||
|
orderNo: string;
|
||||||
|
titleType: InvoiceTitleType;
|
||||||
|
invoiceKind: InvoiceKind;
|
||||||
|
titleName: string;
|
||||||
|
taxNo?: string;
|
||||||
|
addressPhone?: string;
|
||||||
|
bankAccount?: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
remark?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InvoicesPage() {
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/admin/invoices',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createForm] = Form.useForm<CreateFormValues>();
|
||||||
|
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||||
|
const titleType = Form.useWatch('titleType', createForm);
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
setDetail(await request(`/admin/invoices/${id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueWithFile(file: File) {
|
||||||
|
if (!detail) return false;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadFileToOss(file, { bizType: 'invoice' });
|
||||||
|
await request(`/admin/invoices/${detail.id}/issue`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ fileUrl: uploaded.url }),
|
||||||
|
});
|
||||||
|
message.success('已开票回传');
|
||||||
|
reload();
|
||||||
|
setDrawerOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '开票失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject() {
|
||||||
|
if (!detail) return;
|
||||||
|
await request(`/admin/invoices/${detail.id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ remark: '驳回' }),
|
||||||
|
});
|
||||||
|
message.success('已驳回');
|
||||||
|
reload();
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
const values = await createForm.validateFields();
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/invoices', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
orderNo: values.orderNo.trim(),
|
||||||
|
titleType: values.titleType,
|
||||||
|
invoiceKind: values.invoiceKind,
|
||||||
|
titleName: values.titleName.trim(),
|
||||||
|
taxNo: values.taxNo?.trim() || undefined,
|
||||||
|
addressPhone: values.addressPhone?.trim() || undefined,
|
||||||
|
bankAccount: values.bankAccount?.trim() || undefined,
|
||||||
|
email: values.email.trim(),
|
||||||
|
phone: values.phone.trim(),
|
||||||
|
remark: values.remark?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('发票申请已创建');
|
||||||
|
setCreateOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||||
|
{
|
||||||
|
title: '抬头',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '票种',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||||
|
},
|
||||||
|
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => (
|
||||||
|
<>
|
||||||
|
<Tag color={r.status === 'ISSUED' ? 'green' : r.status === 'REJECTED' ? 'red' : 'orange'}>
|
||||||
|
{INVOICE_STATUS_LABELS[r.status] ?? r.status}
|
||||||
|
</Tag>
|
||||||
|
{r.overdue ? <Tag color="red">超时</Tag> : null}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
发票管理
|
||||||
|
</Typography.Title>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
createForm.setFieldsValue({
|
||||||
|
titleType: 'PERSONAL',
|
||||||
|
invoiceKind: 'NORMAL',
|
||||||
|
});
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
创建发票申请
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Form
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'PENDING', label: '待开票' },
|
||||||
|
{ value: 'ISSUED', label: '已开票' },
|
||||||
|
{ value: 'REJECTED', label: '已驳回' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1000 }}
|
||||||
|
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer
|
||||||
|
title="发票详情"
|
||||||
|
width={520}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={
|
||||||
|
detail?.status === 'PENDING' ? (
|
||||||
|
<Space>
|
||||||
|
<Upload
|
||||||
|
accept="image/*,.pdf"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
void issueWithFile(file);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="primary" loading={uploading}>
|
||||||
|
上传并开票
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
<Button danger onClick={() => void reject()}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="申请单号">{detail.invoiceNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单号">{detail.orderNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="金额">{detail.payAmount ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户手机">{detail.userPhone ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="抬头类型">
|
||||||
|
{INVOICE_TITLE_TYPE_LABELS[detail.titleType]}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="发票类型">
|
||||||
|
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行账号">{detail.bankAccount ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="邮箱">{detail.email}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{INVOICE_STATUS_LABELS[detail.status]}
|
||||||
|
{detail.overdue ? '(超 2 工作日)' : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="发票文件">
|
||||||
|
{detail.fileUrl ? (
|
||||||
|
<a href={detail.fileUrl} target="_blank" rel="noreferrer">
|
||||||
|
查看/下载
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{detail.remark ?? '—'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="创建发票申请"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onOk={() => void submitCreate()}
|
||||||
|
confirmLoading={creating}
|
||||||
|
destroyOnClose
|
||||||
|
okText="提交"
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="orderNo"
|
||||||
|
label="订单号"
|
||||||
|
rules={[{ required: true, message: '请填写已完成订单号' }]}
|
||||||
|
extra="仅已完成订单可开票"
|
||||||
|
>
|
||||||
|
<Input placeholder="订单号" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="invoiceKind"
|
||||||
|
label="发票类型"
|
||||||
|
rules={[{ required: true, message: '请选择发票类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => ({
|
||||||
|
value: k,
|
||||||
|
label: INVOICE_KIND_LABELS[k],
|
||||||
|
}))}
|
||||||
|
onChange={(k: InvoiceKind) => {
|
||||||
|
if (k === 'SPECIAL') createForm.setFieldValue('titleType', 'ENTERPRISE');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="titleType"
|
||||||
|
label="抬头类型"
|
||||||
|
rules={[{ required: true, message: '请选择抬头类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => ({
|
||||||
|
value: t,
|
||||||
|
label: INVOICE_TITLE_TYPE_LABELS[t],
|
||||||
|
disabled: invoiceKind === 'SPECIAL' && t === 'PERSONAL',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="titleName"
|
||||||
|
label="抬头名称"
|
||||||
|
rules={[{ required: true, message: '请填写抬头名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="个人姓名或企业全称" />
|
||||||
|
</Form.Item>
|
||||||
|
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
|
||||||
|
<Form.Item
|
||||||
|
name="taxNo"
|
||||||
|
label="税号"
|
||||||
|
rules={[{ required: true, message: '企业抬头须填写税号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="纳税人识别号" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{invoiceKind === 'SPECIAL' && (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name="addressPhone"
|
||||||
|
label="地址电话"
|
||||||
|
rules={[{ required: true, message: '专用发票须填写地址电话' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="注册地址及电话" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="bankAccount"
|
||||||
|
label="开户行账号"
|
||||||
|
rules={[{ required: true, message: '专用发票须填写开户行账号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="开户行及账号" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
label="接收邮箱"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写邮箱' },
|
||||||
|
{ type: 'email', message: '邮箱格式不正确' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="发票发送邮箱" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label="手机号"
|
||||||
|
rules={[{ required: true, message: '请填写手机号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="联系手机" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={2} maxLength={512} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Upload,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
|
import type {
|
||||||
|
KnowledgeBaseDto,
|
||||||
|
KnowledgeDocumentDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocForm = {
|
||||||
|
title: string;
|
||||||
|
contentText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function KnowledgeBasesPage() {
|
||||||
|
const [filterForm] = Form.useForm();
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [docForm] = Form.useForm<DocForm>();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<KnowledgeBaseDto | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [drawerKb, setDrawerKb] = useState<KnowledgeBaseDto | null>(null);
|
||||||
|
const [docs, setDocs] = useState<KnowledgeDocumentDto[]>([]);
|
||||||
|
const [docsLoading, setDocsLoading] = useState(false);
|
||||||
|
const [docModalOpen, setDocModalOpen] = useState(false);
|
||||||
|
const [docSaving, setDocSaving] = useState(false);
|
||||||
|
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||||
|
useAdminList<KnowledgeBaseDto>(
|
||||||
|
'/admin/knowledge-bases',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.name) qs.set('name', filters.name);
|
||||||
|
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadDocs = async (kb: KnowledgeBaseDto) => {
|
||||||
|
setDrawerKb(kb);
|
||||||
|
setDocsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ items: KnowledgeDocumentDto[] }>(
|
||||||
|
`/admin/knowledge-bases/${kb.id}/documents`,
|
||||||
|
);
|
||||||
|
setDocs(res.items);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setDocsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.setFieldsValue({ name: '', description: '', enabled: true });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (row: KnowledgeBaseDto) => {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: row.name,
|
||||||
|
description: row.description || '',
|
||||||
|
enabled: row.enabled,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/knowledge-bases/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
} else {
|
||||||
|
await request('/admin/knowledge-bases', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<KnowledgeBaseDto> = [
|
||||||
|
{ title: '名称', dataIndex: 'name' },
|
||||||
|
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||||
|
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||||
|
{
|
||||||
|
title: '启用',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 90,
|
||||||
|
render: (v: boolean) => (v ? <Tag color="green">开</Tag> : <Tag>关</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建人',
|
||||||
|
dataIndex: 'createdByName',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '更新时间',
|
||||||
|
dataIndex: 'updatedAt',
|
||||||
|
width: 170,
|
||||||
|
render: (t: string) => fmtTime(t),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 240,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => void loadDocs(row)}>
|
||||||
|
文档
|
||||||
|
</Button>
|
||||||
|
{row.canEditFull ? (
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{row.canEditFull ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除知识库及全部文档?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
try {
|
||||||
|
await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
|
||||||
|
{ title: '标题', dataIndex: 'title' },
|
||||||
|
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (s: string) =>
|
||||||
|
s === 'READY' ? <Tag color="green">可检索</Tag> : s === 'FAILED' ? <Tag color="red">失败</Tag> : <Tag>无正文</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 170,
|
||||||
|
render: (t: string) => fmtTime(t),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) =>
|
||||||
|
drawerKb?.canEditFull ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="删除文档?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
try {
|
||||||
|
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
message.success('已删除');
|
||||||
|
void loadDocs(drawerKb);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
知识库
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) =>
|
||||||
|
setFilters({
|
||||||
|
name: v.name || '',
|
||||||
|
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="名称">
|
||||||
|
<Input allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建知识库
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑知识库' : '新建知识库'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void onSave()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="说明">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={drawerKb ? `文档 · ${drawerKb.name}` : '文档'}
|
||||||
|
open={!!drawerKb}
|
||||||
|
width={720}
|
||||||
|
onClose={() => setDrawerKb(null)}
|
||||||
|
extra={
|
||||||
|
drawerKb?.canEditFull ? (
|
||||||
|
<Button type="primary" onClick={() => { docForm.resetFields(); setDocModalOpen(true); }}>
|
||||||
|
上传/粘贴
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={docsLoading}
|
||||||
|
columns={docColumns}
|
||||||
|
dataSource={docs}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="添加知识文档"
|
||||||
|
open={docModalOpen}
|
||||||
|
onCancel={() => setDocModalOpen(false)}
|
||||||
|
confirmLoading={docSaving}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!drawerKb) return;
|
||||||
|
const values = await docForm.validateFields();
|
||||||
|
setDocSaving(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: values.title,
|
||||||
|
contentText: values.contentText || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已添加');
|
||||||
|
setDocModalOpen(false);
|
||||||
|
void loadDocs(drawerKb);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setDocSaving(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
destroyOnClose
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={docForm} layout="vertical">
|
||||||
|
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="contentText" label="正文(可粘贴)">
|
||||||
|
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="或上传文本文件">
|
||||||
|
<Upload
|
||||||
|
accept=".txt,.md,.markdown,.csv,.json,.log,text/*"
|
||||||
|
maxCount={1}
|
||||||
|
customRequest={async ({ file, onSuccess, onError }) => {
|
||||||
|
if (!drawerKb) return;
|
||||||
|
try {
|
||||||
|
const raw = file as File;
|
||||||
|
const uploaded = await uploadFileToOss(raw, {
|
||||||
|
bizType: 'KB_DOCUMENT',
|
||||||
|
mediaType: 'FILE',
|
||||||
|
});
|
||||||
|
const title =
|
||||||
|
docForm.getFieldValue('title') || raw.name.replace(/\.[^.]+$/, '');
|
||||||
|
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
fileName: raw.name,
|
||||||
|
fileUrl: uploaded.url,
|
||||||
|
mimeType: raw.type || null,
|
||||||
|
sizeBytes: raw.size,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('上传成功');
|
||||||
|
setDocModalOpen(false);
|
||||||
|
void loadDocs(drawerKb);
|
||||||
|
reload();
|
||||||
|
onSuccess?.(uploaded);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
|
message.error(err.message);
|
||||||
|
onError?.(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showUploadList={false}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>上传到 OSS 并入库</Button>
|
||||||
|
</Upload>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
LLM_PROVIDERS,
|
||||||
|
LLM_PROVIDER_LABELS,
|
||||||
|
LLM_PROVIDER_PRESETS,
|
||||||
|
type LlmApiConfigDto,
|
||||||
|
type LlmProvider,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
name: string;
|
||||||
|
provider: LlmProvider;
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
modelName?: string;
|
||||||
|
temperature?: number | null;
|
||||||
|
maxTokens?: number | null;
|
||||||
|
systemPrompt?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LlmConfigsPage() {
|
||||||
|
const [filterForm] = Form.useForm();
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<LlmApiConfigDto | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const providerWatch = Form.useWatch('provider', form);
|
||||||
|
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<LlmApiConfigDto>(
|
||||||
|
'/admin/llm-configs',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.name) qs.set('name', filters.name);
|
||||||
|
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providerWatch || editing) return;
|
||||||
|
const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider];
|
||||||
|
if (!preset) return;
|
||||||
|
form.setFieldsValue({
|
||||||
|
baseUrl: preset.defaultBaseUrl || undefined,
|
||||||
|
modelName: preset.defaultModel || undefined,
|
||||||
|
});
|
||||||
|
}, [providerWatch, editing, form]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: '',
|
||||||
|
provider: 'DEEPSEEK',
|
||||||
|
baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl,
|
||||||
|
apiKey: '',
|
||||||
|
modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel,
|
||||||
|
temperature: 0.3,
|
||||||
|
maxTokens: 1024,
|
||||||
|
systemPrompt: '',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (row: LlmApiConfigDto) => {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: row.name,
|
||||||
|
provider: row.provider,
|
||||||
|
baseUrl: row.baseUrl,
|
||||||
|
apiKey: '',
|
||||||
|
modelName: row.modelName,
|
||||||
|
temperature: row.temperature,
|
||||||
|
maxTokens: row.maxTokens,
|
||||||
|
systemPrompt: row.systemPrompt || '',
|
||||||
|
enabled: row.enabled,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
if (!editing.canEditFull) {
|
||||||
|
await request(`/admin/llm-configs/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ enabled: values.enabled }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await request(`/admin/llm-configs/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: values.name,
|
||||||
|
provider: values.provider,
|
||||||
|
baseUrl: values.baseUrl,
|
||||||
|
apiKey: values.apiKey || undefined,
|
||||||
|
modelName: values.modelName,
|
||||||
|
temperature: values.temperature ?? null,
|
||||||
|
maxTokens: values.maxTokens ?? null,
|
||||||
|
systemPrompt: values.systemPrompt || null,
|
||||||
|
enabled: values.enabled,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.success('已保存');
|
||||||
|
} else {
|
||||||
|
await request('/admin/llm-configs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: values.name,
|
||||||
|
provider: values.provider,
|
||||||
|
baseUrl: values.baseUrl,
|
||||||
|
apiKey: values.apiKey,
|
||||||
|
modelName: values.modelName,
|
||||||
|
temperature: values.temperature ?? null,
|
||||||
|
maxTokens: values.maxTokens ?? null,
|
||||||
|
systemPrompt: values.systemPrompt || null,
|
||||||
|
enabled: values.enabled,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<LlmApiConfigDto> = [
|
||||||
|
{ title: '名称', dataIndex: 'name' },
|
||||||
|
{
|
||||||
|
title: '提供商',
|
||||||
|
dataIndex: 'provider',
|
||||||
|
width: 120,
|
||||||
|
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||||
|
},
|
||||||
|
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'Key',
|
||||||
|
dataIndex: 'apiKeyConfigured',
|
||||||
|
width: 80,
|
||||||
|
render: (v: boolean) => (v ? <Tag color="green">已配</Tag> : <Tag>无</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '生效',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 90,
|
||||||
|
render: (v: boolean, row) => (
|
||||||
|
<Switch
|
||||||
|
checked={v}
|
||||||
|
disabled={!row.isOwner && !row.canEditFull}
|
||||||
|
onChange={async (checked) => {
|
||||||
|
try {
|
||||||
|
await request(`/admin/llm-configs/${row.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ enabled: checked }),
|
||||||
|
});
|
||||||
|
message.success(checked ? '已启用' : '已停用');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建人',
|
||||||
|
dataIndex: 'createdByName',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '更新时间',
|
||||||
|
dataIndex: 'updatedAt',
|
||||||
|
width: 170,
|
||||||
|
render: (t: string) => fmtTime(t),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 220,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => openEdit(row)}
|
||||||
|
>
|
||||||
|
{row.canEditFull ? '编辑' : '开关'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await request<{ reply: string }>(`/admin/llm-configs/${row.id}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
message.success(res.reply || '测试成功');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
{row.canEditFull ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
try {
|
||||||
|
await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fullEdit = !editing || editing.canEditFull;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
语言模型配置
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) =>
|
||||||
|
setFilters({
|
||||||
|
name: v.name || '',
|
||||||
|
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="名称">
|
||||||
|
<Input allowClear placeholder="搜索" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="生效">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 100 }}
|
||||||
|
options={[
|
||||||
|
{ value: true, label: '开' },
|
||||||
|
{ value: false, label: '关' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建配置
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? (fullEdit ? '编辑语言模型' : '修改是否生效') : '新建语言模型'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void onSave()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '必填' }]}>
|
||||||
|
<Input disabled={!fullEdit} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
disabled={!fullEdit}
|
||||||
|
options={LLM_PROVIDERS.map((p) => ({ value: p, label: LLM_PROVIDER_LABELS[p] }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="baseUrl"
|
||||||
|
label="Base URL"
|
||||||
|
rules={[{ required: fullEdit, message: '必填' }]}
|
||||||
|
extra="填主机根地址即可,如 https://api.deepseek.com(不要带 /v1)"
|
||||||
|
>
|
||||||
|
<Input disabled={!fullEdit} placeholder="https://api.deepseek.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="apiKey"
|
||||||
|
label="API Key"
|
||||||
|
rules={editing ? [] : [{ required: true, message: '必填' }]}
|
||||||
|
extra={editing ? '留空表示不修改' : undefined}
|
||||||
|
>
|
||||||
|
<Input.Password disabled={!fullEdit} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="modelName" label="模型名" rules={[{ required: fullEdit, message: '必填' }]}>
|
||||||
|
<Input disabled={!fullEdit} />
|
||||||
|
</Form.Item>
|
||||||
|
<Space style={{ display: 'flex' }} size="large">
|
||||||
|
<Form.Item name="temperature" label="Temperature">
|
||||||
|
<InputNumber disabled={!fullEdit} min={0} max={2} step={0.1} style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="maxTokens" label="Max Tokens">
|
||||||
|
<InputNumber disabled={!fullEdit} min={1} max={128000} style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name="systemPrompt" label="系统提示词">
|
||||||
|
<Input.TextArea disabled={!fullEdit} rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="生效" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DatePicker,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {
|
||||||
|
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||||||
|
type LogisticsSettlementMethod,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type BillRow = {
|
||||||
|
id: string;
|
||||||
|
billNo: string;
|
||||||
|
fulfillmentProviderId: string;
|
||||||
|
providerCode?: string;
|
||||||
|
providerName?: string;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
orderCount: number;
|
||||||
|
bottleCount: number;
|
||||||
|
logisticsAmount: number;
|
||||||
|
settlementMethod: string;
|
||||||
|
status: string;
|
||||||
|
paidAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BillItem = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
quantity: number;
|
||||||
|
logisticsAmount: number;
|
||||||
|
shippedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderSummary = {
|
||||||
|
providerId: string;
|
||||||
|
providerCode: string;
|
||||||
|
providerName: string;
|
||||||
|
settlementMethod: string;
|
||||||
|
prepaidBalance: number;
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankName?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
orderCount: number;
|
||||||
|
bottleCount: number;
|
||||||
|
logisticsAmount: number;
|
||||||
|
billId?: string | null;
|
||||||
|
billStatus?: string | null;
|
||||||
|
pricingRules?: {
|
||||||
|
baseBottles: number;
|
||||||
|
baseFee: number;
|
||||||
|
extraBottleFee: number;
|
||||||
|
boxBottles?: number;
|
||||||
|
boxFee?: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
UNPAID: '未结算',
|
||||||
|
PAID: '已结算',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
UNPAID: 'red',
|
||||||
|
PAID: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LogisticsBillsPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [summaryForm] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<BillRow>(
|
||||||
|
'/admin/logistics-bills',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.providerId) qs.set('providerId', filters.providerId);
|
||||||
|
if (filters.year) qs.set('year', filters.year);
|
||||||
|
if (filters.month) qs.set('month', filters.month);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||||
|
const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [providers, setProviders] = useState<Array<{ id: string; code: string; name: string }>>([]);
|
||||||
|
const [summaryRows, setSummaryRows] = useState<ProviderSummary[]>([]);
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||||
|
const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month'));
|
||||||
|
const [genOpen, setGenOpen] = useState(false);
|
||||||
|
const [genForm] = Form.useForm();
|
||||||
|
const [rechargeOpen, setRechargeOpen] = useState(false);
|
||||||
|
const [rechargeTarget, setRechargeTarget] = useState<ProviderSummary | null>(null);
|
||||||
|
const [rechargeForm] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<Array<{ id: string; code: string; name: string }>>('/admin/fulfillment-providers')
|
||||||
|
.then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name }))))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadSummary(period: Dayjs) {
|
||||||
|
setSummaryLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ items: ProviderSummary[] }>(
|
||||||
|
`/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`,
|
||||||
|
);
|
||||||
|
setSummaryRows(res.items ?? []);
|
||||||
|
} finally {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSummary(summaryPeriod);
|
||||||
|
}, [summaryPeriod]);
|
||||||
|
|
||||||
|
function confirmPay(ids: string[], amountHint?: number) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认结算?',
|
||||||
|
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
|
||||||
|
okText: '确认结算',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
if (ids.length === 1) {
|
||||||
|
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||||
|
} else {
|
||||||
|
await request('/admin/logistics-bills/batch-confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.success('已确认结算');
|
||||||
|
setSelectedKeys([]);
|
||||||
|
reload();
|
||||||
|
void loadSummary(summaryPeriod);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
const d = await request<BillRow & { items?: BillItem[] }>(`/admin/logistics-bills/${id}`);
|
||||||
|
setDetail(d);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportExcel() {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.providerId) qs.set('providerId', filters.providerId);
|
||||||
|
if (filters.year) qs.set('year', filters.year);
|
||||||
|
if (filters.month) qs.set('month', filters.month);
|
||||||
|
const result = await request<{ csv: string; count: number }>(
|
||||||
|
`/admin/logistics-bills/export?${qs}`,
|
||||||
|
);
|
||||||
|
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||||
|
downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`);
|
||||||
|
message.success(`已导出 ${result.count} 条`);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateBills() {
|
||||||
|
const v = await genForm.validateFields();
|
||||||
|
const month = v.month as Dayjs;
|
||||||
|
await request('/admin/logistics-bills/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
year: month.year(),
|
||||||
|
month: month.month() + 1,
|
||||||
|
providerId: v.providerId || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已生成物流对账单');
|
||||||
|
setGenOpen(false);
|
||||||
|
reload();
|
||||||
|
void loadSummary(summaryPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRecharge() {
|
||||||
|
if (!rechargeTarget) return;
|
||||||
|
const v = await rechargeForm.validateFields();
|
||||||
|
await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ amount: v.amount, remark: v.remark }),
|
||||||
|
});
|
||||||
|
message.success('充值成功');
|
||||||
|
setRechargeOpen(false);
|
||||||
|
void loadSummary(summaryPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = data?.summary;
|
||||||
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
||||||
|
|
||||||
|
const billColumns: ColumnsType<BillRow> = [
|
||||||
|
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '承运商',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账期',
|
||||||
|
width: 200,
|
||||||
|
render: (_, r) =>
|
||||||
|
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算方式',
|
||||||
|
dataIndex: 'settlementMethod',
|
||||||
|
width: 100,
|
||||||
|
render: (v) =>
|
||||||
|
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||||||
|
},
|
||||||
|
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
|
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||||||
|
{
|
||||||
|
title: '物流费',
|
||||||
|
dataIndex: 'logisticsAmount',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 160,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
|
明细
|
||||||
|
</Button>
|
||||||
|
{row.status === 'UNPAID' && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => confirmPay([row.id], Number(row.logisticsAmount))}
|
||||||
|
>
|
||||||
|
确认结算
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const summaryColumns: ColumnsType<ProviderSummary> = [
|
||||||
|
{
|
||||||
|
title: '承运商',
|
||||||
|
render: (_, r) => `${r.providerName} (${r.providerCode})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算方式',
|
||||||
|
dataIndex: 'settlementMethod',
|
||||||
|
width: 110,
|
||||||
|
render: (v) =>
|
||||||
|
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '计价标准',
|
||||||
|
width: 220,
|
||||||
|
render: (_, r) => {
|
||||||
|
const p = r.pricingRules;
|
||||||
|
if (!p) return '—';
|
||||||
|
return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${
|
||||||
|
p.boxBottles ? `,${p.boxBottles}瓶箱¥${p.boxFee}` : ''
|
||||||
|
}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '银行账户',
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.bankAccountName
|
||||||
|
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
|
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||||||
|
{
|
||||||
|
title: '应付物流费',
|
||||||
|
dataIndex: 'logisticsAmount',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '充值余额',
|
||||||
|
dataIndex: 'prepaidBalance',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账单',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.billStatus ? (
|
||||||
|
<Tag color={STATUS_COLORS[r.billStatus] || 'default'}>
|
||||||
|
{STATUS_LABELS[r.billStatus] || r.billStatus}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
'未生成'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.settlementMethod === 'PREPAID' ? (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setRechargeTarget(r);
|
||||||
|
rechargeForm.resetFields();
|
||||||
|
setRechargeOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
充值
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
物流对账
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'summary',
|
||||||
|
label: '按承运商汇总',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form
|
||||||
|
form={summaryForm}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
initialValues={{ month: summaryPeriod }}
|
||||||
|
onFinish={(v: { month?: Dayjs }) => {
|
||||||
|
const m = v.month || dayjs().subtract(1, 'month');
|
||||||
|
setSummaryPeriod(m);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="month" label="账期">
|
||||||
|
<DatePicker picker="month" allowClear={false} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table
|
||||||
|
rowKey="providerId"
|
||||||
|
loading={summaryLoading}
|
||||||
|
columns={summaryColumns}
|
||||||
|
dataSource={summaryRows}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bills',
|
||||||
|
label: '月账单',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
{summary && (
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space size="large" wrap>
|
||||||
|
<Statistic title="账单数" value={summary.count} />
|
||||||
|
<Statistic
|
||||||
|
title="瓶数合计"
|
||||||
|
value={summary.bottleCount ?? 0}
|
||||||
|
/>
|
||||||
|
<Statistic
|
||||||
|
title="物流费合计"
|
||||||
|
value={summary.logisticsAmount ?? summary.totalAmount ?? 0}
|
||||||
|
prefix="¥"
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v: {
|
||||||
|
status?: string;
|
||||||
|
providerId?: string;
|
||||||
|
month?: Dayjs;
|
||||||
|
}) => {
|
||||||
|
setFilters({
|
||||||
|
status: v.status || '',
|
||||||
|
providerId: v.providerId || '',
|
||||||
|
year: v.month ? String(v.month.year()) : '',
|
||||||
|
month: v.month ? String(v.month.month() + 1) : '',
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'UNPAID', label: '未结算' },
|
||||||
|
{ value: 'PAID', label: '已结算' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="providerId" label="承运商">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 180 }}
|
||||||
|
options={providers.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.name} (${p.code})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="month" label="账期">
|
||||||
|
<DatePicker picker="month" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
genForm.setFieldsValue({ month: dayjs().subtract(1, 'month') });
|
||||||
|
setGenOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
生成账单
|
||||||
|
</Button>
|
||||||
|
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!selectedKeys.length}
|
||||||
|
onClick={() =>
|
||||||
|
confirmPay(
|
||||||
|
selectedKeys.map(String),
|
||||||
|
selectedAmount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
批量结算
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={billColumns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedKeys,
|
||||||
|
onChange: setSelectedKeys,
|
||||||
|
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="物流对账明细"
|
||||||
|
width={640}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="承运商">
|
||||||
|
{detail.providerName} ({detail.providerCode})
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账期">
|
||||||
|
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="物流费">
|
||||||
|
¥{Number(detail.logisticsAmount).toFixed(2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{STATUS_LABELS[detail.status] || detail.status}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结算时间">
|
||||||
|
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={detail.items ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo' },
|
||||||
|
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
|
||||||
|
{
|
||||||
|
title: '物流费',
|
||||||
|
dataIndex: 'logisticsAmount',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '发货时间',
|
||||||
|
dataIndex: 'shippedAt',
|
||||||
|
width: 160,
|
||||||
|
render: fmtTime,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="生成物流月账单"
|
||||||
|
open={genOpen}
|
||||||
|
onCancel={() => setGenOpen(false)}
|
||||||
|
onOk={() => void generateBills()}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={genForm} layout="vertical">
|
||||||
|
<Form.Item name="month" label="账期月" rules={[{ required: true }]}>
|
||||||
|
<DatePicker picker="month" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="providerId" label="承运商(空=全部)">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
options={providers.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.name} (${p.code})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={rechargeTarget ? `充值 · ${rechargeTarget.providerName}` : '充值'}
|
||||||
|
open={rechargeOpen}
|
||||||
|
onCancel={() => setRechargeOpen(false)}
|
||||||
|
onOk={() => void submitRecharge()}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={rechargeForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="amount"
|
||||||
|
label="金额"
|
||||||
|
rules={[{ required: true, message: '请输入充值金额' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input placeholder="可选,如预充运费" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
|
|||||||
function openAddSub(parent: AccountTreeRow) {
|
function openAddSub(parent: AccountTreeRow) {
|
||||||
setSubParent(parent);
|
setSubParent(parent);
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Form,
|
Form,
|
||||||
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@@ -29,6 +30,7 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
periodStart: string;
|
periodStart: string;
|
||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
|
rejectReason?: string | null;
|
||||||
partner?: { companyName?: string; phone?: string };
|
partner?: { companyName?: string; phone?: string };
|
||||||
partnerAccount?: { companyName?: string; phone?: string };
|
partnerAccount?: { companyName?: string; phone?: string };
|
||||||
};
|
};
|
||||||
@@ -36,15 +38,19 @@ type Row = {
|
|||||||
type PartnerOption = { id: string; companyName: string; phone?: string };
|
type PartnerOption = { id: string; companyName: string; phone?: string };
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: '草稿',
|
PENDING_REVIEW: '待审核',
|
||||||
CONFIRMED: '已确认',
|
AWAITING_CONFIRM: '待合伙人确认',
|
||||||
|
UNPAID: '未打款',
|
||||||
PAID: '已打款',
|
PAID: '已打款',
|
||||||
|
REJECTED: '已驳回',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
DRAFT: 'default',
|
PENDING_REVIEW: 'gold',
|
||||||
CONFIRMED: 'blue',
|
AWAITING_CONFIRM: 'blue',
|
||||||
|
UNPAID: 'red',
|
||||||
PAID: 'green',
|
PAID: 'green',
|
||||||
|
REJECTED: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PartnerBillsPage() {
|
export default function PartnerBillsPage() {
|
||||||
@@ -66,6 +72,11 @@ export default function PartnerBillsPage() {
|
|||||||
const [genOpen, setGenOpen] = useState(false);
|
const [genOpen, setGenOpen] = useState(false);
|
||||||
const [genForm] = Form.useForm();
|
const [genForm] = Form.useForm();
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
|
||||||
|
const [rejectForm] = Form.useForm();
|
||||||
|
const [rejecting, setRejecting] = useState(false);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
@@ -73,10 +84,10 @@ export default function PartnerBillsPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) {
|
async function generateBill(values: { partnerId?: string; month: Dayjs }) {
|
||||||
const year = values.month.year();
|
const year = values.month.year();
|
||||||
const month = values.month.month() + 1;
|
const month = values.month.month() + 1;
|
||||||
if (values.all || !values.partnerId) {
|
if (!values.partnerId) {
|
||||||
const result = await request<{ success: number; failed: number; total: number }>(
|
const result = await request<{ success: number; failed: number; total: number }>(
|
||||||
'/admin/partner-bills/generate-all',
|
'/admin/partner-bills/generate-all',
|
||||||
{ method: 'POST', body: JSON.stringify({ year, month }) },
|
{ method: 'POST', body: JSON.stringify({ year, month }) },
|
||||||
@@ -87,25 +98,88 @@ export default function PartnerBillsPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
|
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
|
||||||
});
|
});
|
||||||
message.success('账单已生成');
|
message.success('账单已生成(待审核)');
|
||||||
}
|
}
|
||||||
setGenOpen(false);
|
setGenOpen(false);
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmBill(id: string) {
|
function sendBills(ids: string[]) {
|
||||||
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
|
Modal.confirm({
|
||||||
message.success('已确认');
|
title: '发送给合伙人?',
|
||||||
reload();
|
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
|
||||||
|
okText: '确认发送',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
if (ids.length === 1) {
|
||||||
|
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
|
||||||
|
} else {
|
||||||
|
await request('/admin/partner-bills/batch-send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.success('已发送');
|
||||||
|
setSelectedKeys([]);
|
||||||
|
reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markPaid(id: string) {
|
function markPaid(ids: string[], amountHint?: number) {
|
||||||
await request(`/admin/partner-bills/${id}/mark-paid`, {
|
Modal.confirm({
|
||||||
method: 'POST',
|
title: '确认打款?',
|
||||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。`,
|
||||||
|
okText: '确认打款',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
if (ids.length === 1) {
|
||||||
|
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await request('/admin/partner-bills/batch-mark-paid', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.success('已标记打款');
|
||||||
|
setSelectedKeys([]);
|
||||||
|
reload();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
message.success('已标记打款');
|
}
|
||||||
reload();
|
|
||||||
|
function openReject(row: Row) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '驳回该账单?',
|
||||||
|
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
|
||||||
|
okText: '继续填写理由',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => {
|
||||||
|
setRejectTarget(row);
|
||||||
|
rejectForm.resetFields();
|
||||||
|
setRejectOpen(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReject(values: { reason: string }) {
|
||||||
|
if (!rejectTarget) return;
|
||||||
|
setRejecting(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reason: values.reason.trim() }),
|
||||||
|
});
|
||||||
|
message.success('已驳回');
|
||||||
|
setRejectOpen(false);
|
||||||
|
setRejectTarget(null);
|
||||||
|
reload();
|
||||||
|
} finally {
|
||||||
|
setRejecting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportExcel() {
|
async function exportExcel() {
|
||||||
@@ -127,6 +201,10 @@ export default function PartnerBillsPage() {
|
|||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
||||||
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
|
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
||||||
|
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
|
||||||
|
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
|
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
|
||||||
@@ -163,23 +241,42 @@ export default function PartnerBillsPage() {
|
|||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 130,
|
||||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
render: (s, row) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
|
||||||
|
{s === 'REJECTED' && row.rejectReason ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.rejectReason}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 160,
|
width: 220,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
<Space size={0} wrap>
|
||||||
{row.status === 'DRAFT' && (
|
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
|
||||||
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
|
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
|
||||||
确认
|
发送
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{row.status === 'CONFIRMED' && (
|
{row.status === 'UNPAID' && (
|
||||||
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
|
<>
|
||||||
标记打款
|
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
|
||||||
|
确认打款
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{row.status === 'AWAITING_CONFIRM' && (
|
||||||
|
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
||||||
|
驳回
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -194,7 +291,7 @@ export default function PartnerBillsPage() {
|
|||||||
合伙人账单
|
合伙人账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
T+30 结算:按自然月汇总酒单佣金(酒单价 × 酒单佣金比例)与门店核销佣金(核销额 × 核销佣金比例)
|
每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -242,7 +339,7 @@ export default function PartnerBillsPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
style={{ width: 150 }}
|
||||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -272,6 +369,20 @@ export default function PartnerBillsPage() {
|
|||||||
导出 Excel
|
导出 Excel
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button disabled={!canSend.length} onClick={() => sendBills(canSend.map((r) => r.id))}>
|
||||||
|
批量发送 ({canSend.length})
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={!canPay.length}
|
||||||
|
onClick={() => markPaid(canPay.map((r) => r.id), payAmount)}
|
||||||
|
>
|
||||||
|
批量打款 ({canPay.length})
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -280,7 +391,11 @@ export default function PartnerBillsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedKeys,
|
||||||
|
onChange: setSelectedKeys,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1280 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -293,7 +408,7 @@ export default function PartnerBillsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal title="生成合伙人账单(T+30)" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||||
<Form
|
<Form
|
||||||
form={genForm}
|
form={genForm}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
@@ -316,7 +431,37 @@ export default function PartnerBillsPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit" block>
|
<Button type="primary" htmlType="submit" block>
|
||||||
生成
|
生成(待审核)
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="驳回打款申请"
|
||||||
|
open={rejectOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setRejectOpen(false);
|
||||||
|
setRejectTarget(null);
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
账单 {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
|
||||||
|
<Form.Item
|
||||||
|
name="reason"
|
||||||
|
label="驳回理由"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写驳回理由' },
|
||||||
|
{ max: 500, message: '不超过 500 字' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
|
||||||
|
确认驳回
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Cascader,
|
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
@@ -24,9 +23,10 @@ import {
|
|||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,6 +37,7 @@ type Row = {
|
|||||||
cityId?: string | null;
|
cityId?: string | null;
|
||||||
cityName?: string | null;
|
cityName?: string | null;
|
||||||
scopeType?: string;
|
scopeType?: string;
|
||||||
|
districtCodes?: string[] | null;
|
||||||
orderCommissionRate?: number;
|
orderCommissionRate?: number;
|
||||||
redeemCommissionRate?: number;
|
redeemCommissionRate?: number;
|
||||||
storeCount: number;
|
storeCount: number;
|
||||||
@@ -102,6 +103,18 @@ export default function PartnersPage() {
|
|||||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
|
||||||
|
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
|
||||||
|
|
||||||
|
function formatApiError(err: unknown): string | null {
|
||||||
|
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||||
|
if (!(err instanceof Error)) return '操作失败';
|
||||||
|
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||||
|
const label = districtCodeLabel(code);
|
||||||
|
return label !== code ? `${label}(${code})` : code;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const loadCities = useCallback(async () => {
|
const loadCities = useCallback(async () => {
|
||||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
setCities(res.items);
|
setCities(res.items);
|
||||||
@@ -142,22 +155,35 @@ export default function PartnersPage() {
|
|||||||
|
|
||||||
async function savePartner() {
|
async function savePartner() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const v = await editForm.validateFields();
|
try {
|
||||||
const body = {
|
const v = await editForm.validateFields();
|
||||||
...v,
|
const body = {
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
...v,
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
};
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
};
|
||||||
message.success('已保存');
|
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
setDrawerOpen(false);
|
message.success('已保存');
|
||||||
void reload();
|
setDrawerOpen(false);
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
|
||||||
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
||||||
|
{
|
||||||
|
title: '区县',
|
||||||
|
dataIndex: 'districtCodes',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
|
},
|
||||||
|
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||||
{
|
{
|
||||||
title: '管辖',
|
title: '管辖',
|
||||||
@@ -222,17 +248,21 @@ export default function PartnersPage() {
|
|||||||
label: '基本信息',
|
label: '基本信息',
|
||||||
children: (
|
children: (
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
<Form.Item name="districtCodes" label="区县">
|
<Form.Item
|
||||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
name="districtCodes"
|
||||||
|
label="区县"
|
||||||
|
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
|
>
|
||||||
|
<CityDistrictMultiSelect cityCode={editCityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
<Space style={{ width: '100%' }} size="large">
|
<Space style={{ width: '100%' }} size="large">
|
||||||
@@ -286,39 +316,52 @@ export default function PartnersPage() {
|
|||||||
width={560}
|
width={560}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
onOk={async () => {
|
onOk={async () => {
|
||||||
const v = await createForm.validateFields();
|
try {
|
||||||
await request('/admin/partners', {
|
const v = await createForm.validateFields();
|
||||||
method: 'POST',
|
await request('/admin/partners', {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
...v,
|
body: JSON.stringify({
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
...v,
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
}),
|
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
});
|
}),
|
||||||
message.success('已创建');
|
});
|
||||||
setCreateOpen(false);
|
message.success('已创建');
|
||||||
createForm.resetFields();
|
setCreateOpen(false);
|
||||||
void reload();
|
createForm.resetFields();
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
|
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
|
||||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||||
onChange={(id) => { setCreateCityId(id); void loadWarehouses(id); }}
|
onChange={(id) => {
|
||||||
|
setCreateCityId(id);
|
||||||
|
createForm.setFieldsValue({ districtCodes: undefined });
|
||||||
|
void loadWarehouses(id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
name="districtCodes"
|
||||||
|
label="区县"
|
||||||
|
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
|
>
|
||||||
|
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
<Space style={{ width: '100%' }} size="large">
|
<Space style={{ width: '100%' }} size="large">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||||
Table, Tabs, Tag, Typography, message,
|
Switch, Table, Tabs, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -31,6 +31,7 @@ type Row = {
|
|||||||
benefitAmount: number;
|
benefitAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
allowOnSitePickup?: boolean;
|
||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
carouselUrls?: string[];
|
carouselUrls?: string[];
|
||||||
detailImageUrls?: string[];
|
detailImageUrls?: string[];
|
||||||
@@ -49,6 +50,7 @@ type ProductFormValues = {
|
|||||||
benefitAmount?: number;
|
benefitAmount?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
|
allowOnSitePickup?: boolean;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
carouselUrls?: string[];
|
carouselUrls?: string[];
|
||||||
detailImageUrls?: string[];
|
detailImageUrls?: string[];
|
||||||
@@ -100,6 +102,7 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
benefitAmount: v.benefitAmount,
|
benefitAmount: v.benefitAmount,
|
||||||
status: v.status,
|
status: v.status,
|
||||||
sortOrder: v.sortOrder,
|
sortOrder: v.sortOrder,
|
||||||
|
allowOnSitePickup: !!v.allowOnSitePickup,
|
||||||
coverUrl: v.coverUrl,
|
coverUrl: v.coverUrl,
|
||||||
carouselUrls,
|
carouselUrls,
|
||||||
detailImageUrls,
|
detailImageUrls,
|
||||||
@@ -217,6 +220,9 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
|
|||||||
<Form.Item name="sortOrder" label="排序">
|
<Form.Item name="sortOrder" label="排序">
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="coverUrl" label="封面">
|
<Form.Item name="coverUrl" label="封面">
|
||||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -265,7 +271,15 @@ export default function ProductsPage() {
|
|||||||
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
|
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
|
||||||
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
|
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
|
||||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
|
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
|
||||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
||||||
|
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
) },
|
||||||
|
{
|
||||||
|
title: '现场取货',
|
||||||
|
dataIndex: 'allowOnSitePickup',
|
||||||
|
width: 90,
|
||||||
|
render: (v: boolean) => (v ? <Tag color="green">允许</Tag> : <Tag>否</Tag>),
|
||||||
|
},
|
||||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -357,7 +371,7 @@ export default function ProductsPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}} width={720}>
|
}} width={720}>
|
||||||
<Form form={createForm} layout="vertical" initialValues={{
|
<Form form={createForm} layout="vertical" initialValues={{
|
||||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
|
||||||
carouselUrls: [''], detailImageUrls: [''],
|
carouselUrls: [''], detailImageUrls: [''],
|
||||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -1,236 +1,244 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import {
|
||||||
|
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||||
import {
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
import {
|
||||||
|
PROMO_CODE_SCENE_LABELS,
|
||||||
} from 'antd';
|
PROMO_CODE_STATUS_LABELS,
|
||||||
|
promoConversion,
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
type PromoCodeItem,
|
||||||
|
type PromoCodeScene,
|
||||||
import {
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
PROMO_CODE_SCENE_LABELS,
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
PROMO_CODE_STATUS_LABELS,
|
|
||||||
|
type Row = PromoCodeItem;
|
||||||
promoConversion,
|
|
||||||
|
type SceneOption = { value: PromoCodeScene; label: string };
|
||||||
type PromoCodeItem,
|
|
||||||
|
async function downloadQrcode(url: string, filename: string) {
|
||||||
type PromoCodeScene,
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
} from '@dukang/shared-types';
|
const blob = await res.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
import { request } from '../lib/api';
|
const a = document.createElement('a');
|
||||||
|
a.href = objectUrl;
|
||||||
import { fmtTime } from '../lib/constants';
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
} catch {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
type Row = PromoCodeItem;
|
}
|
||||||
|
|
||||||
|
export default function PromoCodesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
type SceneOption = { value: PromoCodeScene; label: string };
|
const [filterForm] = Form.useForm();
|
||||||
|
const [createForm] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [scenes, setScenes] = useState<SceneOption[]>(
|
||||||
async function downloadQrcode(url: string, filename: string) {
|
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
|
||||||
|
value: value as PromoCodeScene,
|
||||||
try {
|
label,
|
||||||
|
})),
|
||||||
const res = await fetch(url);
|
);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const blob = await res.blob();
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/admin/promo-codes',
|
||||||
const a = document.createElement('a');
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
a.href = objectUrl;
|
if (filters.name) qs.set('name', filters.name);
|
||||||
|
if (filters.code) qs.set('code', filters.code);
|
||||||
a.download = filename;
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.scene) qs.set('scene', filters.scene);
|
||||||
a.click();
|
return qs;
|
||||||
|
},
|
||||||
URL.revokeObjectURL(objectUrl);
|
[filters],
|
||||||
|
);
|
||||||
} catch {
|
|
||||||
|
async function loadScenes() {
|
||||||
window.open(url, '_blank');
|
try {
|
||||||
|
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
|
||||||
}
|
if (list.length) setScenes(list);
|
||||||
|
} catch {
|
||||||
}
|
/* 使用本地默认场景 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function PromoCodesPage() {
|
useEffect(() => {
|
||||||
|
void loadScenes();
|
||||||
const navigate = useNavigate();
|
}, []);
|
||||||
|
|
||||||
const [filterForm] = Form.useForm();
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||||
const [createForm] = Form.useForm();
|
{ title: '码值', dataIndex: 'code', width: 110 },
|
||||||
|
{
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
title: '场景',
|
||||||
|
dataIndex: 'scene',
|
||||||
const [scenes, setScenes] = useState<SceneOption[]>(
|
width: 110,
|
||||||
|
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
|
||||||
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
|
},
|
||||||
|
{
|
||||||
value: value as PromoCodeScene,
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
label,
|
width: 90,
|
||||||
|
render: (s) => (
|
||||||
})),
|
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
|
||||||
|
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
|
||||||
);
|
</Tag>
|
||||||
|
),
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
},
|
||||||
|
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
||||||
const [creating, setCreating] = useState(false);
|
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||||
|
{
|
||||||
|
title: '转化率',
|
||||||
|
width: 90,
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
||||||
|
},
|
||||||
'/admin/promo-codes',
|
{
|
||||||
|
title: '渠道负责人',
|
||||||
() => {
|
width: 120,
|
||||||
|
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
|
||||||
const qs = new URLSearchParams();
|
},
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
if (filters.name) qs.set('name', filters.name);
|
{
|
||||||
|
title: '操作',
|
||||||
if (filters.code) qs.set('code', filters.code);
|
width: 220,
|
||||||
|
fixed: 'right',
|
||||||
if (filters.status) qs.set('status', filters.status);
|
render: (_, row) => (
|
||||||
|
<Space size="small" wrap>
|
||||||
if (filters.scene) qs.set('scene', filters.scene);
|
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
||||||
|
详情
|
||||||
return qs;
|
</Button>
|
||||||
|
<Button
|
||||||
},
|
type="link"
|
||||||
|
size="small"
|
||||||
[filters],
|
disabled={!row.qrcodeUrl}
|
||||||
|
onClick={() => {
|
||||||
);
|
if (!row.qrcodeUrl) return;
|
||||||
|
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
async function loadScenes() {
|
下载小程序码
|
||||||
|
</Button>
|
||||||
try {
|
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
||||||
|
关联用户
|
||||||
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
|
</Button>
|
||||||
|
</Space>
|
||||||
if (list.length) setScenes(list);
|
),
|
||||||
|
},
|
||||||
} catch {
|
];
|
||||||
|
|
||||||
/* 使用本地默认场景 */
|
async function handleCreate(values: Record<string, string>) {
|
||||||
|
setCreating(true);
|
||||||
}
|
try {
|
||||||
|
const created = await request<PromoCodeItem>('/admin/promo-codes', {
|
||||||
}
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: values.name,
|
||||||
|
code: values.code?.trim() || undefined,
|
||||||
useEffect(() => {
|
scene: values.scene,
|
||||||
|
ownerUserId: values.ownerUserId?.trim() || undefined,
|
||||||
void loadScenes();
|
remark: values.remark?.trim() || undefined,
|
||||||
|
page: values.page?.trim() || undefined,
|
||||||
}, []);
|
}),
|
||||||
|
});
|
||||||
|
message.success('推广码已创建');
|
||||||
|
setCreateOpen(false);
|
||||||
const columns: ColumnsType<Row> = [
|
createForm.resetFields();
|
||||||
|
void reload();
|
||||||
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
|
navigate(`/promo-codes/${created.id}`);
|
||||||
|
} catch (e) {
|
||||||
{ title: '码值', dataIndex: 'code', width: 110 },
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
{
|
setCreating(false);
|
||||||
|
}
|
||||||
title: '场景',
|
}
|
||||||
|
|
||||||
dataIndex: 'scene',
|
return (
|
||||||
|
<div>
|
||||||
width: 110,
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
||||||
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
|
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
||||||
|
</div>
|
||||||
},
|
|
||||||
|
<Form
|
||||||
{
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
title: '状态',
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => { setFilters(v); setPage(1); }}
|
||||||
dataIndex: 'status',
|
>
|
||||||
|
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||||
width: 90,
|
<Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
|
||||||
|
<Form.Item name="scene" label="场景">
|
||||||
render: (s) => (
|
<Select allowClear style={{ width: 130 }} options={scenes} />
|
||||||
|
</Form.Item>
|
||||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
|
allowClear
|
||||||
|
style={{ width: 100 }}
|
||||||
</Tag>
|
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
),
|
</Form.Item>
|
||||||
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
},
|
</Form>
|
||||||
|
|
||||||
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
<Table
|
||||||
|
rowKey="id"
|
||||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
{
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
title: '转化率',
|
scroll={{ x: 1200 }}
|
||||||
|
pagination={{
|
||||||
width: 90,
|
current: page,
|
||||||
|
pageSize,
|
||||||
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
},
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||||
|
}}
|
||||||
{
|
/>
|
||||||
|
|
||||||
title: '渠道负责人',
|
<Modal
|
||||||
|
title="创建推广码"
|
||||||
width: 120,
|
open={createOpen}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
|
footer={null}
|
||||||
|
destroyOnClose
|
||||||
},
|
>
|
||||||
|
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
<Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
|
<Input placeholder="如:郑州品鉴会、门店地推" />
|
||||||
{
|
</Form.Item>
|
||||||
|
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
|
||||||
title: '操作',
|
<Select options={scenes} />
|
||||||
|
</Form.Item>
|
||||||
width: 220,
|
<Form.Item name="code" label="自定义码值(选填)">
|
||||||
|
<Input placeholder="留空自动生成,如 DKDEMO1" />
|
||||||
fixed: 'right',
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
render: (_, row) => (
|
name="page"
|
||||||
|
label="小程序落地页(选填)"
|
||||||
<Space size="small" wrap>
|
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
|
||||||
|
>
|
||||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
<Input placeholder="pages/home/index" />
|
||||||
|
</Form.Item>
|
||||||
详情
|
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
|
||||||
|
<Input placeholder="渠道负责人,填写用户数据库 ID" />
|
||||||
</Button>
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
<Button
|
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={creating} block>
|
||||||
|
创建并生成小程序码
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
@@ -45,12 +45,33 @@ export default function StoreAccountsPage() {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
|
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
|
||||||
|
|
||||||
async function loadStores() {
|
async function loadStores() {
|
||||||
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
setStores(res.items);
|
setStores(res.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshDetail(accountId: string) {
|
||||||
|
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
|
||||||
|
setDetail(d);
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStaff(staffId: string) {
|
||||||
|
if (!detail) return;
|
||||||
|
setDeletingStaffId(staffId);
|
||||||
|
try {
|
||||||
|
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
|
||||||
|
message.success('子账号已删除');
|
||||||
|
await refreshDetail(detail.id);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
} finally {
|
||||||
|
setDeletingStaffId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
@@ -218,10 +239,32 @@ export default function StoreAccountsPage() {
|
|||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, staff) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该子账号?"
|
||||||
|
description={`${staff.name}(${staff.phone})删除后将无法登录门店端`}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
|
||||||
|
cancelText="取消"
|
||||||
|
onConfirm={() => void deleteStaff(staff.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
|
||||||
|
暂无子账号
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Statistic,
|
Statistic,
|
||||||
@@ -23,26 +24,26 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
|
billNo: string;
|
||||||
|
billDate: string;
|
||||||
|
redeemCount: number;
|
||||||
redeemAmount: number;
|
redeemAmount: number;
|
||||||
payoutAmount: number;
|
|
||||||
settlementRate: number;
|
settlementRate: number;
|
||||||
|
payoutAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
expectedPayAt: string;
|
paidAt?: string | null;
|
||||||
paidAt?: string;
|
|
||||||
createdAt: string;
|
|
||||||
store?: { id: string; name: string; cityName: string; phone?: string };
|
store?: { id: string; name: string; cityName: string; phone?: string };
|
||||||
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoreOption = { id: string; name: string; phone: string };
|
type StoreOption = { id: string; name: string; phone: string };
|
||||||
|
|
||||||
const PAYOUT_STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING: '待打款',
|
UNPAID: '未打款',
|
||||||
PAID: '已打款',
|
PAID: '已打款',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAYOUT_STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
PENDING: 'orange',
|
UNPAID: 'red',
|
||||||
PAID: 'green',
|
PAID: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ export default function StoreBillsPage() {
|
|||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/store-payouts',
|
'/admin/store-bills',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
@@ -65,6 +66,8 @@ export default function StoreBillsPage() {
|
|||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
@@ -72,13 +75,37 @@ export default function StoreBillsPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function confirmPayout(id: string) {
|
function confirmPay(ids: string[], amountHint?: number) {
|
||||||
await request(`/admin/store-payouts/${id}/confirm`, {
|
Modal.confirm({
|
||||||
method: 'POST',
|
title: '确认打款?',
|
||||||
body: JSON.stringify({ remark: '财务确认打款' }),
|
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||||
|
okText: '确认打款',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
if (ids.length === 1) {
|
||||||
|
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||||
|
} else {
|
||||||
|
setBatchLoading(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/store-bills/batch-confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBatchLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.success('已确认打款');
|
||||||
|
setSelectedKeys([]);
|
||||||
|
reload();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
message.success('已确认打款');
|
}
|
||||||
reload();
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
|
||||||
|
setDetail(d);
|
||||||
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportExcel() {
|
async function exportExcel() {
|
||||||
@@ -89,10 +116,8 @@ export default function StoreBillsPage() {
|
|||||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||||
const result = await request<{ csv: string; count: number }>(
|
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
||||||
`/admin/store-payouts/export?${qs}`,
|
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||||
);
|
|
||||||
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
|
||||||
message.success(`已导出 ${result.count} 条`);
|
message.success(`已导出 ${result.count} 条`);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
@@ -100,62 +125,56 @@ export default function StoreBillsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '核销日期',
|
title: '账单日',
|
||||||
|
dataIndex: 'billDate',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (_, r) =>
|
render: (v) => String(v || '').slice(0, 10),
|
||||||
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
|
|
||||||
},
|
},
|
||||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||||||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||||
|
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
|
||||||
{
|
{
|
||||||
title: '核销单号',
|
title: '核销金额',
|
||||||
dataIndex: ['redeemRecord', 'redeemNo'],
|
dataIndex: 'redeemAmount',
|
||||||
width: 160,
|
width: 110,
|
||||||
ellipsis: true,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
render: (v) => v || '—',
|
|
||||||
},
|
},
|
||||||
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
|
|
||||||
{
|
{
|
||||||
title: '核销比例',
|
title: '结算比例',
|
||||||
dataIndex: 'settlementRate',
|
dataIndex: 'settlementRate',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
|
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '应付门店',
|
title: '应付金额',
|
||||||
dataIndex: 'payoutAmount',
|
dataIndex: 'payoutAmount',
|
||||||
width: 100,
|
width: 110,
|
||||||
render: (v) => `¥${v}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '打款状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 90,
|
||||||
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
|
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 140,
|
width: 160,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
<Space size={0}>
|
||||||
<Button
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
type="link"
|
明细
|
||||||
size="small"
|
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/store-payouts/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
详情
|
|
||||||
</Button>
|
</Button>
|
||||||
{row.status === 'PENDING' && (
|
{row.status === 'UNPAID' && (
|
||||||
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
|
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
|
||||||
确认打款
|
确认打款
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -168,19 +187,19 @@ export default function StoreBillsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
门店账单
|
门店对账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
T+1 结算:按日列出门店核销订单,应付 = 核销面额 × 门店核销比例
|
按核销日汇总(每日 8:00 自动出账);未打款红色、已打款绿色
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<Space size="large" wrap>
|
<Space size="large" wrap>
|
||||||
<Statistic title="明细笔数" value={summary.count} />
|
<Statistic title="账单数" value={summary.count} />
|
||||||
<Statistic title="核销面额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="核销金额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||||||
<Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
<Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} />
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -189,16 +208,12 @@ export default function StoreBillsPage() {
|
|||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
onFinish={(v: {
|
onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => {
|
||||||
storeId?: string;
|
|
||||||
status?: string;
|
|
||||||
dateRange?: [Dayjs, Dayjs];
|
|
||||||
}) => {
|
|
||||||
setFilters({
|
setFilters({
|
||||||
storeId: v.storeId || '',
|
storeId: v.storeId || '',
|
||||||
status: v.status || '',
|
status: v.status || '',
|
||||||
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
|
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||||
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
|
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||||
});
|
});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
@@ -210,21 +225,17 @@ export default function StoreBillsPage() {
|
|||||||
placeholder="全部门店"
|
placeholder="全部门店"
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
options={stores.map((s) => ({
|
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
|
||||||
value: s.id,
|
|
||||||
label: `${s.name}(${s.phone})`,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="打款状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
placeholder="全部"
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="dateRange" label="核销日期">
|
<Form.Item name="range" label="账单日">
|
||||||
<DatePicker.RangePicker />
|
<DatePicker.RangePicker />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
@@ -248,6 +259,16 @@ export default function StoreBillsPage() {
|
|||||||
导出 Excel
|
导出 Excel
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={!selectedKeys.length}
|
||||||
|
loading={batchLoading}
|
||||||
|
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
||||||
|
>
|
||||||
|
批量确认打款 ({selectedKeys.length})
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -256,7 +277,12 @@ export default function StoreBillsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1300 }}
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedKeys,
|
||||||
|
onChange: setSelectedKeys,
|
||||||
|
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -269,28 +295,44 @@ export default function StoreBillsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<>
|
||||||
<Descriptions.Item label="门店">
|
<Descriptions column={1} size="small" bordered>
|
||||||
{String((detail.store as { name?: string })?.name ?? '—')}
|
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
||||||
</Descriptions.Item>
|
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="核销单号">
|
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||||
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
|
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
|
||||||
</Descriptions.Item>
|
<Descriptions.Item label="打款时间">
|
||||||
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||||
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="结算比例">
|
</Descriptions>
|
||||||
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
</Descriptions.Item>
|
核销明细
|
||||||
<Descriptions.Item label="状态">
|
</Typography.Title>
|
||||||
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
<Table
|
||||||
</Descriptions.Item>
|
size="small"
|
||||||
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
|
rowKey="id"
|
||||||
<Descriptions.Item label="实际打款">
|
pagination={false}
|
||||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
|
||||||
</Descriptions.Item>
|
columns={[
|
||||||
</Descriptions>
|
{
|
||||||
|
title: '核销单号',
|
||||||
|
render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'payoutAmount',
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (s) => (s === 'PAID' ? '已打款' : '未打款'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type CategoryNode = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
parentId: string | null;
|
||||||
|
status: string;
|
||||||
|
children?: CategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string };
|
||||||
|
|
||||||
|
function flattenTree(tree: CategoryNode[]): FlatRow[] {
|
||||||
|
const rows: FlatRow[] = [];
|
||||||
|
for (const root of tree) {
|
||||||
|
rows.push({ ...root, level: 1, children: undefined });
|
||||||
|
for (const child of root.children ?? []) {
|
||||||
|
rows.push({
|
||||||
|
...child,
|
||||||
|
level: 2,
|
||||||
|
parentName: root.name,
|
||||||
|
children: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoreCategoriesPage() {
|
||||||
|
const [tree, setTree] = useState<CategoryNode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<FlatRow | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const rows = useMemo(() => flattenTree(tree), [tree]);
|
||||||
|
const rootOptions = useMemo(
|
||||||
|
() => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })),
|
||||||
|
[tree],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<CategoryNode[]>('/admin/store-categories');
|
||||||
|
setTree(Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openCreate(parentId?: string) {
|
||||||
|
setEditing(null);
|
||||||
|
form.setFieldsValue({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
sort: 0,
|
||||||
|
parentId: parentId || undefined,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: FlatRow) {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue({
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
sort: row.sort,
|
||||||
|
parentId: row.parentId || undefined,
|
||||||
|
status: row.status,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const payload = {
|
||||||
|
code: String(values.code).trim().toUpperCase(),
|
||||||
|
name: String(values.name).trim(),
|
||||||
|
sort: Number(values.sort ?? 0),
|
||||||
|
parentId: values.parentId || null,
|
||||||
|
status: values.status || 'ACTIVE',
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/store-categories/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
} else {
|
||||||
|
await request('/admin/store-categories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<FlatRow> = [
|
||||||
|
{
|
||||||
|
title: '层级',
|
||||||
|
dataIndex: 'level',
|
||||||
|
width: 80,
|
||||||
|
render: (level) => (level === 1 ? <Tag color="blue">一级</Tag> : <Tag>二级</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (name, row) => (
|
||||||
|
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
||||||
|
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '编码', dataIndex: 'code', width: 140 },
|
||||||
|
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (s) => (
|
||||||
|
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 220,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||||
|
{row.level === 1 ? (
|
||||||
|
<Button type="link" size="small" onClick={() => openCreate(row.id)}>加二级</Button>
|
||||||
|
) : null}
|
||||||
|
<Popconfirm
|
||||||
|
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已处理');
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||||
|
message.success('已同步默认分类');
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
同步默认分类
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
pagination={false}
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑分类' : '新增分类'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
|
||||||
|
<Input placeholder="如 DINING / HOTPOT" disabled={!!editing} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
|
<Input placeholder="分类名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="parentId" label="上级分类(空=一级)">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="不选则为一级分类"
|
||||||
|
options={rootOptions.filter((o) => o.value !== editing?.id)}
|
||||||
|
disabled={editing?.level === 1 && (tree.find((t) => t.id === editing.id)?.children?.length ?? 0) > 0}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sort" label="排序" initialValue={0}>
|
||||||
|
<InputNumber style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'ACTIVE', label: '启用' },
|
||||||
|
{ value: 'DISABLED', label: '停用' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
SUPPORT_TICKET_STATUS_LABELS,
|
||||||
|
SUPPORT_TICKET_TYPE_LABELS,
|
||||||
|
type SupportTicketDto,
|
||||||
|
type SupportTicketStatusDto,
|
||||||
|
type SupportTicketTypeDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request, type HqProfile } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
|
||||||
|
PENDING_REVIEW: 'orange',
|
||||||
|
REJECTED: 'red',
|
||||||
|
DEVELOPING: 'blue',
|
||||||
|
TESTING: 'purple',
|
||||||
|
PASSED: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
|
||||||
|
(value) => ({ value, label: SUPPORT_TICKET_TYPE_LABELS[value] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTicketStatusDto[]).map(
|
||||||
|
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function SupportTicketsPage() {
|
||||||
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||||
|
useAdminList<SupportTicketDto>('/admin/support-tickets', () => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
return qs;
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const [detail, setDetail] = useState<SupportTicketDto | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [acting, setActing] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [createForm] = Form.useForm<{
|
||||||
|
ticketType: SupportTicketTypeDto;
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
remark?: string;
|
||||||
|
}>();
|
||||||
|
const [rejectForm] = Form.useForm<{ rejectReason: string }>();
|
||||||
|
|
||||||
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
setDetail(await request<SupportTicketDto>(`/admin/support-tickets/${id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
const values = await createForm.validateFields();
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/support-tickets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticketType: values.ticketType,
|
||||||
|
title: values.title.trim(),
|
||||||
|
content: values.content?.trim() || undefined,
|
||||||
|
remark: values.remark?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('技术支持工单已创建,等待最高管理员评审');
|
||||||
|
setCreateOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve() {
|
||||||
|
if (!detail) return;
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/support-tickets/${detail.id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
message.success('评审通过,已进入开发');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReject() {
|
||||||
|
if (!detail) return;
|
||||||
|
const values = await rejectForm.validateFields();
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/support-tickets/${detail.id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ rejectReason: values.rejectReason.trim() }),
|
||||||
|
});
|
||||||
|
message.success('已驳回');
|
||||||
|
setRejectOpen(false);
|
||||||
|
rejectForm.resetFields();
|
||||||
|
setDrawerOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTesting() {
|
||||||
|
if (!detail) return;
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/support-tickets/${detail.id}/start-testing`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
message.success('已转入测试');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pass() {
|
||||||
|
if (!detail) return;
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/support-tickets/${detail.id}/pass`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
message.success('测试通过');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<SupportTicketDto> = [
|
||||||
|
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'ticketType',
|
||||||
|
width: 90,
|
||||||
|
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s: SupportTicketStatusDto) => (
|
||||||
|
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||||
|
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={() => void openDetail(String(row.id))}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const drawerExtra = (() => {
|
||||||
|
if (!detail) return null;
|
||||||
|
if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" loading={acting} onClick={() => void approve()}>
|
||||||
|
评审通过
|
||||||
|
</Button>
|
||||||
|
<Button danger loading={acting} onClick={() => setRejectOpen(true)}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (detail.status === 'DEVELOPING') {
|
||||||
|
return (
|
||||||
|
<Button type="primary" loading={acting} onClick={() => void startTesting()}>
|
||||||
|
开发完成 · 转入测试
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (detail.status === 'TESTING') {
|
||||||
|
return (
|
||||||
|
<Button type="primary" loading={acting} onClick={() => void pass()}>
|
||||||
|
测试通过
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
技术支持
|
||||||
|
</Typography.Title>
|
||||||
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
|
创建工单
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="ticketType" label="类型">
|
||||||
|
<Select allowClear style={{ width: 120 }} options={TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="技术支持详情"
|
||||||
|
width={520}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={drawerExtra}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="工单号">{detail.ticketNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="类型">
|
||||||
|
{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={STATUS_COLOR[detail.status]}>
|
||||||
|
{SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="标题">{detail.title}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="内容">
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap' }}>{detail.content || '—'}</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建人">{detail.creatorName}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="评审人">{detail.reviewerName || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="评审时间">
|
||||||
|
{detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="驳回理由">{detail.rejectReason || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="创建技术支持工单"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onOk={() => void submitCreate()}
|
||||||
|
confirmLoading={creating}
|
||||||
|
destroyOnClose
|
||||||
|
okText="提交"
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG' }}>
|
||||||
|
<Form.Item
|
||||||
|
name="ticketType"
|
||||||
|
label="类型"
|
||||||
|
rules={[{ required: true, message: '请选择类型' }]}
|
||||||
|
>
|
||||||
|
<Select options={TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="标题"
|
||||||
|
rules={[{ required: true, message: '请填写标题' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="简要描述问题或建议" maxLength={128} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="content" label="详细说明">
|
||||||
|
<Input.TextArea rows={5} placeholder="复现步骤、期望结果等" maxLength={4000} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="可选" maxLength={512} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="驳回技术支持工单"
|
||||||
|
open={rejectOpen}
|
||||||
|
onCancel={() => setRejectOpen(false)}
|
||||||
|
onOk={() => void submitReject()}
|
||||||
|
confirmLoading={acting}
|
||||||
|
destroyOnClose
|
||||||
|
okText="确认驳回"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Form form={rejectForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="rejectReason"
|
||||||
|
label="驳回理由"
|
||||||
|
rules={[{ required: true, message: '请填写驳回理由' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={4} placeholder="必填" maxLength={512} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,573 +1,409 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
|
||||||
Alert,
|
Alert,
|
||||||
|
|
||||||
Button,
|
Button,
|
||||||
|
|
||||||
Card,
|
Card,
|
||||||
|
|
||||||
Collapse,
|
Collapse,
|
||||||
|
|
||||||
Form,
|
Form,
|
||||||
|
|
||||||
Input,
|
Input,
|
||||||
|
|
||||||
InputNumber,
|
InputNumber,
|
||||||
|
Modal,
|
||||||
Space,
|
Space,
|
||||||
|
|
||||||
Switch,
|
Switch,
|
||||||
|
|
||||||
Table,
|
Table,
|
||||||
|
|
||||||
Tag,
|
Tag,
|
||||||
|
|
||||||
Typography,
|
Typography,
|
||||||
|
|
||||||
message,
|
message,
|
||||||
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
|
||||||
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
|
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
|
||||||
|
import { request, type HqProfile } from '../lib/api';
|
||||||
import { request } from '../lib/api';
|
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
|
function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}>
|
<div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}>
|
||||||
|
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
|
||||||
最近 Mock 验证码(写入数据库,最新 50 条)
|
最近 Mock 验证码(写入数据库,最新 50 条)
|
||||||
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
||||||
<Table<MockSmsCodeItem>
|
<Table<MockSmsCodeItem>
|
||||||
|
|
||||||
size="small"
|
size="small"
|
||||||
|
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
|
||||||
scroll={{ y: 240 }}
|
scroll={{ y: 240 }}
|
||||||
|
|
||||||
locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }}
|
locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }}
|
||||||
|
|
||||||
columns={[
|
columns={[
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
title: '时间',
|
title: '时间',
|
||||||
|
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
|
|
||||||
width: 168,
|
width: 168,
|
||||||
|
|
||||||
render: (v: string) => new Date(v).toLocaleString(),
|
render: (v: string) => new Date(v).toLocaleString(),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||||
|
|
||||||
{ title: '场景', dataIndex: 'scene', width: 160 },
|
{ title: '场景', dataIndex: 'scene', width: 160 },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
title: '验证码',
|
title: '验证码',
|
||||||
|
|
||||||
dataIndex: 'code',
|
dataIndex: 'code',
|
||||||
|
|
||||||
width: 88,
|
width: 88,
|
||||||
|
|
||||||
render: (code: string) => (
|
render: (code: string) => (
|
||||||
|
|
||||||
<Typography.Text copyable strong>
|
<Typography.Text copyable strong>
|
||||||
|
|
||||||
{code}
|
{code}
|
||||||
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
||||||
),
|
),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
]}
|
]}
|
||||||
|
|
||||||
dataSource={codes}
|
dataSource={codes}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function renderField(
|
function renderField(
|
||||||
|
|
||||||
field: SystemConfigFieldMeta,
|
field: SystemConfigFieldMeta,
|
||||||
|
|
||||||
configuredSecrets: string[],
|
configuredSecrets: string[],
|
||||||
|
|
||||||
extra?: ReactNode,
|
extra?: ReactNode,
|
||||||
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
|
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
|
||||||
|
|
||||||
if (field.type === 'boolean') {
|
if (field.type === 'boolean') {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div key={field.key}>
|
<div key={field.key}>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|
||||||
name={field.key}
|
name={field.key}
|
||||||
|
|
||||||
label={
|
label={
|
||||||
|
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
|
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
|
|
||||||
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||||
|
|
||||||
{field.key}
|
{field.key}
|
||||||
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
||||||
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||||
|
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tooltip={field.description}
|
tooltip={field.description}
|
||||||
|
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
|
|
||||||
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
|
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
|
||||||
|
|
||||||
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
|
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<Switch />
|
<Switch />
|
||||||
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{extra}
|
{extra}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'image' || field.type === 'imageList') {
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={field.key}
|
||||||
|
name={field.key}
|
||||||
|
label={
|
||||||
|
<Space size={4} wrap>
|
||||||
|
<span>{field.label}</span>
|
||||||
|
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||||
|
{field.key}
|
||||||
|
</Typography.Text>
|
||||||
|
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
tooltip={field.description}
|
||||||
|
trigger="onChange"
|
||||||
|
getValueFromEvent={(v: unknown) => (typeof v === 'string' ? v : '')}
|
||||||
|
normalize={(v) => (typeof v === 'string' ? v : '')}
|
||||||
|
>
|
||||||
|
{field.type === 'image' ? (
|
||||||
|
<ConfigImageField bizType="footer" />
|
||||||
|
) : (
|
||||||
|
<ConfigImageListField bizType="swiper" />
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const input =
|
const input =
|
||||||
|
|
||||||
field.type === 'textarea' ? (
|
field.type === 'textarea' ? (
|
||||||
|
|
||||||
<TextArea rows={3} placeholder={field.placeholder} />
|
<TextArea rows={3} placeholder={field.placeholder} />
|
||||||
|
|
||||||
) : field.type === 'number' ? (
|
) : field.type === 'number' ? (
|
||||||
|
|
||||||
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
|
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
|
||||||
|
|
||||||
) : field.secret ? (
|
) : field.secret ? (
|
||||||
|
|
||||||
<Input.Password
|
<Input.Password
|
||||||
|
|
||||||
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
|
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
|
||||||
|
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<Input placeholder={field.placeholder} />
|
<Input placeholder={field.placeholder} />
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|
||||||
key={field.key}
|
key={field.key}
|
||||||
|
|
||||||
name={field.key}
|
name={field.key}
|
||||||
|
|
||||||
label={
|
label={
|
||||||
|
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
|
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
|
|
||||||
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||||
|
|
||||||
{field.key}
|
{field.key}
|
||||||
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
||||||
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||||
|
|
||||||
{isConfiguredSecret ? <Tag>已配置</Tag> : null}
|
{isConfiguredSecret ? <Tag>已配置</Tag> : null}
|
||||||
|
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tooltip={field.description}
|
tooltip={field.description}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{input}
|
{input}
|
||||||
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function SystemSettingsPage() {
|
export default function SystemSettingsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm<Record<string, string>>();
|
const [form] = Form.useForm<Record<string, string>>();
|
||||||
|
|
||||||
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
|
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
|
||||||
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
const bypassLeaveRef = useRef(false);
|
||||||
const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true';
|
const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true';
|
||||||
|
|
||||||
|
dirtyRef.current = dirty;
|
||||||
|
|
||||||
async function load(silent = false) {
|
async function load(silent = false) {
|
||||||
|
|
||||||
if (!silent) setLoading(true);
|
if (!silent) setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const data = await request<SystemConfigFormResponse>('/admin/system-config');
|
const data = await request<SystemConfigFormResponse>('/admin/system-config');
|
||||||
|
if (silent) {
|
||||||
|
setMeta((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
|
||||||
|
: data,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setMeta(data);
|
setMeta(data);
|
||||||
|
|
||||||
form.setFieldsValue(data.values);
|
form.setFieldsValue(data.values);
|
||||||
|
setDirty(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
||||||
if (!silent) message.error(e instanceof Error ? e.message : '加载失败');
|
if (!silent) message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
if (!silent) setLoading(false);
|
if (!silent) setLoading(false);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
if (!mockSmsEnabled) return;
|
if (!mockSmsEnabled) return;
|
||||||
|
|
||||||
const timer = window.setInterval(() => void load(true), 5000);
|
const timer = window.setInterval(() => void load(true), 5000);
|
||||||
|
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
|
|
||||||
}, [mockSmsEnabled]);
|
}, [mockSmsEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (!dirtyRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (!dirtyRef.current || bypassLeaveRef.current) return;
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
const anchor = target?.closest?.('a');
|
||||||
|
if (!anchor || !(anchor instanceof HTMLAnchorElement)) return;
|
||||||
|
if (anchor.target === '_blank' || anchor.hasAttribute('download')) return;
|
||||||
|
const url = new URL(anchor.href, window.location.href);
|
||||||
|
if (url.origin !== window.location.origin) return;
|
||||||
|
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
Modal.confirm({
|
||||||
|
title: '有未保存的更改',
|
||||||
|
content: '离开前请先保存,否则更改将丢失。',
|
||||||
|
okText: '仍要离开',
|
||||||
|
cancelText: '留下',
|
||||||
|
onOk: () => {
|
||||||
|
bypassLeaveRef.current = true;
|
||||||
|
setDirty(false);
|
||||||
|
navigate(`${url.pathname}${url.search}${url.hash}`);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
bypassLeaveRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onDocClick, true);
|
||||||
|
return () => document.removeEventListener('click', onDocClick, true);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const collapseItems = useMemo(() => {
|
const collapseItems = useMemo(() => {
|
||||||
|
|
||||||
if (!meta) return [];
|
if (!meta) return [];
|
||||||
|
|
||||||
return meta.groups.map((group) => ({
|
return meta.groups.map((group) => ({
|
||||||
|
|
||||||
key: group.key,
|
key: group.key,
|
||||||
|
|
||||||
label: group.label,
|
label: group.label,
|
||||||
|
forceRender: true,
|
||||||
children: (
|
children: (
|
||||||
|
|
||||||
<div style={{ maxWidth: 720 }}>
|
<div style={{ maxWidth: 720 }}>
|
||||||
|
|
||||||
{meta.fields
|
{meta.fields
|
||||||
|
|
||||||
.filter((f) => f.group === group.key)
|
.filter((f) => f.group === group.key)
|
||||||
|
|
||||||
.map((f) =>
|
.map((f) =>
|
||||||
|
|
||||||
renderField(
|
renderField(
|
||||||
|
|
||||||
f,
|
f,
|
||||||
|
|
||||||
meta.configuredSecrets,
|
meta.configuredSecrets,
|
||||||
|
|
||||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||||
|
|
||||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||||
|
|
||||||
) : undefined,
|
) : undefined,
|
||||||
|
|
||||||
),
|
),
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
),
|
),
|
||||||
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
}, [meta, mockSmsEnabled, loading]);
|
}, [meta, mockSmsEnabled, loading]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function onSave() {
|
async function onSave() {
|
||||||
|
await form.validateFields();
|
||||||
const values = await form.validateFields();
|
const values = form.getFieldsValue(true);
|
||||||
|
|
||||||
const payload: Record<string, string> = {};
|
const payload: Record<string, string> = {};
|
||||||
|
|
||||||
for (const [k, v] of Object.entries(values)) {
|
for (const [k, v] of Object.entries(values)) {
|
||||||
|
|
||||||
payload[k] = v === undefined || v === null ? '' : String(v);
|
payload[k] = v === undefined || v === null ? '' : String(v);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
|
const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
|
||||||
|
|
||||||
'/admin/system-config',
|
'/admin/system-config',
|
||||||
|
|
||||||
{ method: 'PUT', body: JSON.stringify({ values: payload }) },
|
{ method: 'PUT', body: JSON.stringify({ values: payload }) },
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
message.success(`已保存 ${res.updatedKeys.length} 项`);
|
message.success(`已保存 ${res.updatedKeys.length} 项`);
|
||||||
|
|
||||||
if (res.requiresRestartKeys.length) {
|
if (res.requiresRestartKeys.length) {
|
||||||
|
|
||||||
message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
|
message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
setDirty(false);
|
||||||
await load();
|
await load();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
||||||
message.error(e instanceof Error ? e.message : '保存失败');
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function onSyncEnv() {
|
async function onSyncEnv() {
|
||||||
|
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const res = await request<{ message: string; envFilePath: string }>(
|
const res = await request<{ message: string; envFilePath: string }>(
|
||||||
|
|
||||||
'/admin/system-config/sync-env',
|
'/admin/system-config/sync-env',
|
||||||
|
|
||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
message.success(res.message || '已同步到 env 文件');
|
message.success(res.message || '已同步到 env 文件');
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
||||||
message.error(e instanceof Error ? e.message : '同步失败');
|
message.error(e instanceof Error ? e.message : '同步失败');
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
setSyncing(false);
|
setSyncing(false);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function onImportEnv() {
|
async function onImportEnv() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const res = await request<{ imported: number }>('/admin/system-config/import-env', {
|
const res = await request<{ imported: number }>('/admin/system-config/import-env', {
|
||||||
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
message.success(`已从当前进程环境导入 ${res.imported} 项`);
|
message.success(`已从当前进程环境导入 ${res.imported} 项`);
|
||||||
|
|
||||||
await load();
|
await load();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
||||||
message.error(e instanceof Error ? e.message : '导入失败');
|
message.error(e instanceof Error ? e.message : '导入失败');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ paddingBottom: 88 }}>
|
||||||
<div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
|
||||||
系统设置
|
系统设置
|
||||||
|
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
|
||||||
|
|
||||||
配置存于 <code>system_config</code> 表;保存后写入进程环境。可同步到{' '}
|
配置存于 <code>system_config</code> 表;保存后写入进程环境。可同步到{' '}
|
||||||
|
|
||||||
<code>{meta?.envFilePath ?? '.env'}</code> 以便部署持久化。
|
<code>{meta?.envFilePath ?? '.env'}</code> 以便部署持久化。
|
||||||
|
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
|
{profile?.adminRole === 'SUPER_ADMIN' ? (
|
||||||
<Button onClick={() => void onImportEnv()}>从环境导入</Button>
|
<>
|
||||||
|
<Button onClick={() => void onImportEnv()}>从环境导入</Button>
|
||||||
<Button loading={syncing} onClick={() => void onSyncEnv()}>
|
<Button loading={syncing} onClick={() => void onSyncEnv()}>
|
||||||
|
同步到 env 文件
|
||||||
同步到 env 文件
|
</Button>
|
||||||
|
</>
|
||||||
</Button>
|
) : null}
|
||||||
|
|
||||||
<Button type="primary" loading={saving} onClick={() => void onSave()}>
|
|
||||||
|
|
||||||
保存
|
|
||||||
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
|
|
||||||
type="info"
|
type="info"
|
||||||
|
|
||||||
showIcon
|
showIcon
|
||||||
|
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
|
|
||||||
message="生效说明"
|
message="生效说明"
|
||||||
|
|
||||||
description={
|
description={
|
||||||
|
|
||||||
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
|
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
|
|
||||||
<Tag color="green">即时</Tag>:保存后写入 <code>process.env</code>,Mock 开关、短信模板等可立即生效。
|
<Tag color="green">即时</Tag>:保存后写入 <code>process.env</code>,Mock 开关、短信模板等可立即生效。
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
|
|
||||||
<Tag color="orange">需重启</Tag>:微信/OSS 密钥等集成凭证变更后,<strong>建议重启 API 进程</strong>。
|
<Tag color="orange">需重启</Tag>:微信/OSS 密钥等集成凭证变更后,<strong>建议重启 API 进程</strong>。
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>OSS 始终走阿里云配置;凭证缺失时上传接口将直接报错。</li>
|
<li>OSS 始终走阿里云配置;凭证缺失时上传接口将直接报错。</li>
|
||||||
|
<li>修改后请点击右下角「保存」;未保存离开页面将提示确认。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Card loading={loading}>
|
<Card loading={loading}>
|
||||||
|
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
|
||||||
<Form form={form} layout="vertical">
|
<Collapse defaultActiveKey={[]} items={collapseItems} />
|
||||||
|
|
||||||
<Collapse defaultActiveKey={meta?.groups.map((g) => g.key)} items={collapseItems} />
|
|
||||||
|
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{meta?.updatedAt ? (
|
{meta?.updatedAt ? (
|
||||||
|
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
||||||
|
|
||||||
最近更新:{new Date(meta.updatedAt).toLocaleString()}
|
最近更新:{new Date(meta.updatedAt).toLocaleString()}
|
||||||
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: 32,
|
||||||
|
bottom: 32,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dirty ? <Tag color="orange">有未保存更改</Tag> : null}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
loading={saving}
|
||||||
|
onClick={() => void onSave()}
|
||||||
|
style={{
|
||||||
|
minWidth: 120,
|
||||||
|
boxShadow: '0 6px 16px rgba(0,0,0,0.18)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
@@ -8,11 +22,12 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
ticketNo: string;
|
ticketNo: string;
|
||||||
ticketType: string;
|
ticketType: TicketTypeDto;
|
||||||
status: string;
|
status: string;
|
||||||
refType: string;
|
refType: string;
|
||||||
refId: string;
|
refId: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
extraJson?: { evidenceUrls?: string[] };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,8 +43,15 @@ export default function TicketsPage() {
|
|||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createForm] = Form.useForm<{
|
||||||
|
ticketType: TicketTypeDto;
|
||||||
|
orderNo: string;
|
||||||
|
remark?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
async function approve(id: string) {
|
async function approve(id: string) {
|
||||||
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
||||||
@@ -48,57 +70,204 @@ export default function TicketsPage() {
|
|||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
const values = await createForm.validateFields();
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/tickets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticketType: values.ticketType,
|
||||||
|
orderNo: values.orderNo.trim(),
|
||||||
|
remark: values.remark?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('工单已创建');
|
||||||
|
setCreateOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||||
{ title: '类型', dataIndex: 'ticketType', width: 100 },
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'ticketType',
|
||||||
|
width: 110,
|
||||||
|
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
|
||||||
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 80,
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={async () => {
|
<Button
|
||||||
setDetail(await request(`/admin/tickets/${row.id}`));
|
type="link"
|
||||||
setDrawerOpen(true);
|
size="small"
|
||||||
}}>详情</Button>
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/tickets/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>工单中心</Typography.Title>
|
<div
|
||||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
工单中心
|
||||||
|
</Typography.Title>
|
||||||
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
|
创建工单
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Form
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Form.Item name="ticketType" label="类型">
|
<Form.Item name="ticketType" label="类型">
|
||||||
<Select allowClear style={{ width: 120 }} options={[
|
<Select
|
||||||
{ value: 'REFUND', label: '退款' },
|
allowClear
|
||||||
{ value: 'RESHIPMENT', label: '补发' },
|
style={{ width: 140 }}
|
||||||
{ value: 'ALERT', label: '异常' },
|
options={[
|
||||||
]} />
|
{ value: 'REFUND', label: '仅退款' },
|
||||||
|
{ value: 'RESHIPMENT', label: '破损补发' },
|
||||||
|
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
||||||
|
{ value: 'RETURN_REFUND', label: '退货退款' },
|
||||||
|
{ value: 'ALERT', label: '异常' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
|
<Form.Item name="status" label="状态">
|
||||||
<Button type="primary" htmlType="submit">筛选</Button>
|
<Input allowClear placeholder="PENDING" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
<Table
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
rowKey="id"
|
||||||
<Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
loading={loading}
|
||||||
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
columns={columns}
|
||||||
<>
|
dataSource={data?.items ?? []}
|
||||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>通过</Button>
|
scroll={{ x: 900 }}
|
||||||
<Button danger onClick={() => reject(String(detail.id))}>驳回</Button>
|
pagination={{
|
||||||
</>
|
current: page,
|
||||||
) : null}>
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer
|
||||||
|
title="工单详情"
|
||||||
|
width={480}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={
|
||||||
|
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={() => approve(String(detail.id))}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button danger onClick={() => reject(String(detail.id))}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
|
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
|
<Descriptions.Item label="类型">
|
||||||
|
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
|
<Descriptions.Item label="关联">
|
||||||
|
{String(detail.refType)} #{String(detail.refId)}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
|
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="凭证">
|
||||||
|
{evidenceUrls.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
{evidenceUrls.map((url) => (
|
||||||
|
<Image key={url} src={url} width={72} style={{ marginRight: 8 }} />
|
||||||
|
))}
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="创建工单"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onOk={() => void submitCreate()}
|
||||||
|
confirmLoading={creating}
|
||||||
|
destroyOnClose
|
||||||
|
okText="提交"
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'REFUND' }}>
|
||||||
|
<Form.Item
|
||||||
|
name="ticketType"
|
||||||
|
label="工单类型"
|
||||||
|
rules={[{ required: true, message: '请选择类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'REFUND', label: '仅退款' },
|
||||||
|
{ value: 'RESHIPMENT', label: '破损补发' },
|
||||||
|
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
||||||
|
{ value: 'RETURN_REFUND', label: '退货退款' },
|
||||||
|
{ value: 'ALERT', label: '异常' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="orderNo"
|
||||||
|
label="订单号"
|
||||||
|
rules={[{ required: true, message: '请填写订单号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="关联订单号" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={3} placeholder="可选" maxLength={512} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
|
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
|
||||||
import { request, type AdminUserRow, type Paginated } from '../lib/api';
|
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||||
|
|
||||||
type UserOrderRow = {
|
type UserOrderRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -66,6 +66,7 @@ export default function UsersPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -82,6 +83,11 @@ export default function UsersPage() {
|
|||||||
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
|
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
|
||||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||||
const [batchRiskAck, setBatchRiskAck] = useState(false);
|
const [batchRiskAck, setBatchRiskAck] = useState(false);
|
||||||
|
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -222,7 +228,7 @@ export default function UsersPage() {
|
|||||||
title: '手机',
|
title: '手机',
|
||||||
dataIndex: 'phone',
|
dataIndex: 'phone',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v) => v || '—',
|
render: (v) => maskPhone(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '验手机',
|
title: '验手机',
|
||||||
@@ -309,13 +315,15 @@ export default function UsersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
||||||
<Button
|
{canDeleteUsers ? (
|
||||||
danger
|
<Button
|
||||||
disabled={!selectedRowKeys.length}
|
danger
|
||||||
onClick={() => void openBatchDeleteModal()}
|
disabled={!selectedRowKeys.length}
|
||||||
>
|
onClick={() => void openBatchDeleteModal()}
|
||||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
>
|
||||||
</Button>
|
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Form.Item name="phone" label="手机号">
|
<Form.Item name="phone" label="手机号">
|
||||||
@@ -353,11 +361,11 @@ export default function UsersPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1500 }}
|
scroll={{ x: 1500 }}
|
||||||
rowSelection={{
|
rowSelection={canDeleteUsers ? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
preserveSelectedRowKeys: true,
|
preserveSelectedRowKeys: true,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
}}
|
} : undefined}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -375,9 +383,9 @@ export default function UsersPage() {
|
|||||||
width={640}
|
width={640}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={detail && (
|
extra={detail && canDeleteUsers ? (
|
||||||
<Button danger onClick={openDeleteModal}>删除用户</Button>
|
<Button danger onClick={openDeleteModal}>删除用户</Button>
|
||||||
)}
|
) : undefined}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
@@ -385,7 +393,7 @@ export default function UsersPage() {
|
|||||||
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
|
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item>
|
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="验手机时间">
|
<Descriptions.Item label="验手机时间">
|
||||||
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -419,7 +427,7 @@ export default function UsersPage() {
|
|||||||
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="合并至">
|
<Descriptions.Item label="合并至">
|
||||||
{detail.mergedInto
|
{detail.mergedInto
|
||||||
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
|
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
|
||||||
: '—'}
|
: '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||||
@@ -593,7 +601,7 @@ export default function UsersPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
|
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
|
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => maskPhone(v) },
|
||||||
{
|
{
|
||||||
title: '风险',
|
title: '风险',
|
||||||
width: 200,
|
width: 200,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime, maskPhone } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||||
@@ -119,7 +119,7 @@ export default function WechatBindingsPage() {
|
|||||||
title: '手机号',
|
title: '手机号',
|
||||||
dataIndex: 'primaryPhone',
|
dataIndex: 'primaryPhone',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (v) => v || '—',
|
render: (v) => maskPhone(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '身份摘要',
|
title: '身份摘要',
|
||||||
@@ -128,7 +128,7 @@ export default function WechatBindingsPage() {
|
|||||||
<AdminCellLine
|
<AdminCellLine
|
||||||
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
|
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
|
||||||
secondary={r.identities
|
secondary={r.identities
|
||||||
.map((i) => i.refLabel || i.name || i.phone)
|
.map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : ''))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')}
|
.join(' · ')}
|
||||||
/>
|
/>
|
||||||
@@ -164,7 +164,7 @@ export default function WechatBindingsPage() {
|
|||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<AdminCellLine
|
<AdminCellLine
|
||||||
primary={r.name || '—'}
|
primary={r.name || '—'}
|
||||||
secondary={[r.phone, `#${r.actorId}`].filter(Boolean).join(' ')}
|
secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
WECOM_BOT_PERMISSIONS,
|
||||||
|
WECOM_BOT_PERMISSION_LABELS,
|
||||||
|
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
|
||||||
|
WECOM_BOT_ROLE_LABELS,
|
||||||
|
WECOM_BOT_ROLES,
|
||||||
|
type LlmApiConfigOptionDto,
|
||||||
|
type KnowledgeBaseOptionDto,
|
||||||
|
type WecomBotDto,
|
||||||
|
type WecomBotPermission,
|
||||||
|
type WecomBotRole,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import OssUpload from '../components/OssUpload';
|
||||||
|
|
||||||
|
type ListRes = {
|
||||||
|
items: WecomBotDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
runtime?: {
|
||||||
|
masterEnabled: boolean;
|
||||||
|
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
name: string;
|
||||||
|
role: WecomBotRole;
|
||||||
|
botId: string;
|
||||||
|
secret?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
welcome?: string;
|
||||||
|
permissions: WecomBotPermission[];
|
||||||
|
aiEnabled: boolean;
|
||||||
|
llmConfigId?: string | null;
|
||||||
|
knowledgeBaseId?: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WecomBotsPage() {
|
||||||
|
const [filterForm] = Form.useForm();
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<WecomBotDto | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<WecomBotDto | null>(null);
|
||||||
|
const [runtime, setRuntime] = useState<ListRes['runtime']>();
|
||||||
|
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
|
||||||
|
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
|
||||||
|
const roleWatch = Form.useWatch('role', form);
|
||||||
|
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<WecomBotDto>(
|
||||||
|
'/admin/wecom-bots',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.name) qs.set('name', filters.name);
|
||||||
|
if (filters.role) qs.set('role', filters.role);
|
||||||
|
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// useAdminList returns items; runtime comes from same API — fetch once for banner
|
||||||
|
void request<ListRes>('/admin/wecom-bots?page=1&pageSize=1')
|
||||||
|
.then((res) => setRuntime(res.runtime))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<{
|
||||||
|
llmConfigs: LlmApiConfigOptionDto[];
|
||||||
|
knowledgeBases: KnowledgeBaseOptionDto[];
|
||||||
|
}>('/admin/wecom-bots/ai-options')
|
||||||
|
.then((res) => {
|
||||||
|
setLlmOptions(res.llmConfigs);
|
||||||
|
setKbOptions(res.knowledgeBases);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLlmOptions([]);
|
||||||
|
setKbOptions([]);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: '',
|
||||||
|
role: 'CUSTOMER_SERVICE',
|
||||||
|
botId: '',
|
||||||
|
secret: '',
|
||||||
|
avatarUrl: '',
|
||||||
|
welcome: '',
|
||||||
|
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
|
||||||
|
aiEnabled: false,
|
||||||
|
llmConfigId: null,
|
||||||
|
knowledgeBaseId: null,
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: WecomBotDto) {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: row.name,
|
||||||
|
role: row.role,
|
||||||
|
botId: row.botId,
|
||||||
|
secret: '',
|
||||||
|
avatarUrl: row.avatarUrl || '',
|
||||||
|
welcome: row.welcome || '',
|
||||||
|
permissions: row.permissions,
|
||||||
|
aiEnabled: row.aiEnabled,
|
||||||
|
llmConfigId: row.llmConfigId,
|
||||||
|
knowledgeBaseId: row.knowledgeBaseId,
|
||||||
|
enabled: row.enabled,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: values.name.trim(),
|
||||||
|
role: values.role,
|
||||||
|
botId: values.botId.trim(),
|
||||||
|
avatarUrl: values.avatarUrl?.trim() || null,
|
||||||
|
welcome: values.welcome?.trim() || null,
|
||||||
|
permissions: values.permissions,
|
||||||
|
aiEnabled: values.aiEnabled,
|
||||||
|
llmConfigId: values.llmConfigId || null,
|
||||||
|
knowledgeBaseId: values.knowledgeBaseId || null,
|
||||||
|
enabled: values.enabled,
|
||||||
|
sortOrder: values.sortOrder,
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/wecom-bots/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
secret: values.secret?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已更新');
|
||||||
|
} else {
|
||||||
|
if (!values.secret?.trim()) {
|
||||||
|
message.error('请填写 Secret');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await request('/admin/wecom-bots', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
secret: values.secret.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/wecom-bots/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadConnections() {
|
||||||
|
try {
|
||||||
|
const st = await request<{
|
||||||
|
masterEnabled: boolean;
|
||||||
|
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
|
||||||
|
}>('/admin/wecom-bots/reload', { method: 'POST', body: '{}' });
|
||||||
|
message.success('已重载长连接');
|
||||||
|
setRuntime(st);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '重载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
const columns: ColumnsType<WecomBotDto> = [
|
||||||
|
{
|
||||||
|
title: '头像',
|
||||||
|
dataIndex: 'avatarUrl',
|
||||||
|
width: 64,
|
||||||
|
render: (url: string | null, row) => (
|
||||||
|
<Avatar src={url || undefined} shape="square" size={40}>
|
||||||
|
{row.name.slice(0, 1)}
|
||||||
|
</Avatar>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
width: 120,
|
||||||
|
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
|
||||||
|
},
|
||||||
|
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '权限',
|
||||||
|
dataIndex: 'permissions',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (perms: WecomBotPermission[]) =>
|
||||||
|
perms.map((p) => (
|
||||||
|
<Tag key={p} style={{ marginBottom: 2 }}>
|
||||||
|
{WECOM_BOT_PERMISSION_LABELS[p] || p}
|
||||||
|
</Tag>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.aiEnabled ? (
|
||||||
|
<Tag color="blue">{row.llmConfigName || '已开'}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>关</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '启用',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 70,
|
||||||
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '是' : '否'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '连接',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => {
|
||||||
|
const rt = runtimeMap.get(row.id);
|
||||||
|
if (!runtime?.masterEnabled) return <Tag>总开关关</Tag>;
|
||||||
|
if (!row.enabled) return <Tag>未启用</Tag>;
|
||||||
|
return (
|
||||||
|
<Tag color={rt?.connected ? 'green' : 'orange'}>{rt?.connected ? '已连接' : '未连接'}</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确认删除该机器人?" onConfirm={() => remove(row.id)}>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
企微机器人
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 → 功能开关」。
|
||||||
|
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => void reloadConnections()}>重载连接</Button>
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
创建机器人
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="名称">
|
||||||
|
<Input allowClear placeholder="名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="角色">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 160 }}
|
||||||
|
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 100 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: '是' },
|
||||||
|
{ value: 'false', label: '否' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑企微机器人' : '创建企微机器人'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
width={640}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
|
<Input placeholder="如:客服机器人" maxLength={64} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
|
||||||
|
onChange={(role: WecomBotRole) => {
|
||||||
|
form.setFieldValue('permissions', [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="avatarUrl" label="头像">
|
||||||
|
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="botId" label="BotID" rules={[{ required: true, message: '请填写 BotID' }]}>
|
||||||
|
<Input placeholder="企业微信后台长连接 BotID" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="secret"
|
||||||
|
label="Secret"
|
||||||
|
rules={editing ? [] : [{ required: true, message: '请填写 Secret' }]}
|
||||||
|
extra={editing ? '留空表示不修改' : undefined}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={editing ? '留空不修改' : '长连接专用 Secret'} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="permissions"
|
||||||
|
label="权限"
|
||||||
|
rules={[{ required: true, message: '请至少选择一项权限' }]}
|
||||||
|
extra={
|
||||||
|
roleWatch
|
||||||
|
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox.Group
|
||||||
|
options={WECOM_BOT_PERMISSIONS.map((p) => ({
|
||||||
|
value: p,
|
||||||
|
label: WECOM_BOT_PERMISSION_LABELS[p],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="welcome" label="欢迎语">
|
||||||
|
<Input.TextArea rows={3} placeholder="进入会话时的欢迎语,可空" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="aiEnabled"
|
||||||
|
label="启用 AI 问答"
|
||||||
|
valuePropName="checked"
|
||||||
|
extra="开启后,未匹配指令的自然语言将调用绑定的语言模型(可挂知识库)"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="llmConfigId" label="语言模型">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="选择已生效的配置"
|
||||||
|
options={llmOptions.map((o) => ({
|
||||||
|
value: o.id,
|
||||||
|
label: `${o.name}(${o.modelName})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="knowledgeBaseId" label="知识库">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="可选,供 AI 检索"
|
||||||
|
options={kbOptions.map((o) => ({
|
||||||
|
value: o.id,
|
||||||
|
label: `${o.name}(${o.documentCount} 篇)`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Space size="large">
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序">
|
||||||
|
<InputNumber style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer title="机器人详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="头像">
|
||||||
|
<Avatar src={detail.avatarUrl || undefined} size={64} shape="square">
|
||||||
|
{detail.name.slice(0, 1)}
|
||||||
|
</Avatar>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="角色">
|
||||||
|
{WECOM_BOT_ROLE_LABELS[detail.role] || detail.role}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="BotID">{detail.botId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Secret">
|
||||||
|
{detail.secretConfigured ? '已配置' : '未配置'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="权限">
|
||||||
|
{detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="欢迎语">{detail.welcome || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="AI 问答">{detail.aiEnabled ? '开' : '关'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="语言模型">{detail.llmConfigName || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="知识库">{detail.knowledgeBaseName || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="更新">{fmtTime(detail.updatedAt)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Statistic,
|
Statistic,
|
||||||
@@ -14,24 +18,44 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
|
import {
|
||||||
|
WINERY_SETTLEMENT_RATE,
|
||||||
|
type SystemConfigFormResponse,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
import { request } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
orderId: string;
|
id: string;
|
||||||
orderNo: string;
|
billNo: string;
|
||||||
deliveryType: string;
|
billDate: string;
|
||||||
cityName?: string;
|
orderCount: number;
|
||||||
receiverCity?: string;
|
orderAmount: number;
|
||||||
payAmount: number;
|
|
||||||
wineryRate: number;
|
wineryRate: number;
|
||||||
wineryAmount: number;
|
wineryAmount: number;
|
||||||
|
status: string;
|
||||||
|
paidAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BillItem = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
deliveryType: string;
|
||||||
|
payAmount: number;
|
||||||
|
wineryAmount: number;
|
||||||
paidAt: string;
|
paidAt: string;
|
||||||
productName?: string;
|
};
|
||||||
quantity?: number;
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
UNPAID: '未打款',
|
||||||
|
PAID: '已打款',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
UNPAID: 'red',
|
||||||
|
PAID: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DELIVERY_LABELS: Record<string, string> = {
|
const DELIVERY_LABELS: Record<string, string> = {
|
||||||
@@ -39,57 +63,146 @@ const DELIVERY_LABELS: Record<string, string> = {
|
|||||||
CROSS_CITY: '跨城',
|
CROSS_CITY: '跨城',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WINERY_BANK_KEYS = [
|
||||||
|
'WINERY_BANK_ACCOUNT_NAME',
|
||||||
|
'WINERY_BANK_NAME',
|
||||||
|
'WINERY_BANK_BRANCH',
|
||||||
|
'WINERY_BANK_ACCOUNT_NO',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export default function WineryBillsPage() {
|
export default function WineryBillsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [bankForm] = Form.useForm<Record<string, string>>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/winery-bills',
|
'/admin/winery-bills',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
if (filters.year) qs.set('year', filters.year);
|
if (filters.year) qs.set('year', filters.year);
|
||||||
if (filters.month) qs.set('month', filters.month);
|
if (filters.month) qs.set('month', filters.month);
|
||||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||||
|
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||||
|
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
|
const [bankOpen, setBankOpen] = useState(false);
|
||||||
|
const [bankLoading, setBankLoading] = useState(false);
|
||||||
|
const [bankSaving, setBankSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canEditWineryBank =
|
||||||
|
profile?.adminRole === 'SUPER_ADMIN' ||
|
||||||
|
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
|
||||||
|
|
||||||
|
function confirmPay(ids: string[], amountHint?: number) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认打款?',
|
||||||
|
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||||
|
okText: '确认打款',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
if (ids.length === 1) {
|
||||||
|
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||||
|
} else {
|
||||||
|
await request('/admin/winery-bills/batch-confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.success('已确认打款');
|
||||||
|
setSelectedKeys([]);
|
||||||
|
reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
const d = await request<Row & { items?: BillItem[] }>(`/admin/winery-bills/${id}`);
|
||||||
|
setDetail(d);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
async function exportExcel() {
|
async function exportExcel() {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
if (filters.year) qs.set('year', filters.year);
|
if (filters.year) qs.set('year', filters.year);
|
||||||
if (filters.month) qs.set('month', filters.month);
|
if (filters.month) qs.set('month', filters.month);
|
||||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||||
|
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||||
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
|
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
|
||||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||||
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
|
downloadExcelCsv(result.csv, `酒厂对账单_${suffix}.csv`);
|
||||||
message.success(`已导出 ${result.count} 条`);
|
message.success(`已导出 ${result.count} 条`);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openBankModal() {
|
||||||
|
setBankOpen(true);
|
||||||
|
setBankLoading(true);
|
||||||
|
try {
|
||||||
|
const cfg = await request<SystemConfigFormResponse>('/admin/system-config');
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const key of WINERY_BANK_KEYS) {
|
||||||
|
values[key] = cfg.values[key] ?? '';
|
||||||
|
}
|
||||||
|
bankForm.setFieldsValue(values);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setBankOpen(false);
|
||||||
|
} finally {
|
||||||
|
setBankLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBank() {
|
||||||
|
const values = await bankForm.validateFields();
|
||||||
|
setBankSaving(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/system-config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ values }),
|
||||||
|
});
|
||||||
|
message.success('酒厂银行账户已保存');
|
||||||
|
setBankOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setBankSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
||||||
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
|
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '配送类型',
|
title: '账单日',
|
||||||
dataIndex: 'deliveryType',
|
dataIndex: 'billDate',
|
||||||
width: 90,
|
|
||||||
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
|
|
||||||
},
|
|
||||||
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
|
|
||||||
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
|
|
||||||
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
|
|
||||||
{ title: '数量', dataIndex: 'quantity', width: 70 },
|
|
||||||
{
|
|
||||||
title: '酒单实付',
|
|
||||||
dataIndex: 'payAmount',
|
|
||||||
width: 110,
|
width: 110,
|
||||||
|
render: (v) => String(v || '').slice(0, 10),
|
||||||
|
},
|
||||||
|
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
|
{
|
||||||
|
title: '酒单实付合计',
|
||||||
|
dataIndex: 'orderAmount',
|
||||||
|
width: 120,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,30 +218,62 @@ export default function WineryBillsPage() {
|
|||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '支付时间',
|
title: '状态',
|
||||||
dataIndex: 'paidAt',
|
dataIndex: 'status',
|
||||||
width: 170,
|
width: 90,
|
||||||
render: (v) => (v ? fmtTime(v) : '—'),
|
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 160,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
|
明细
|
||||||
|
</Button>
|
||||||
|
{row.status === 'UNPAID' && (
|
||||||
|
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
||||||
|
确认打款
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
<div
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
style={{
|
||||||
酒厂账单
|
display: 'flex',
|
||||||
</Typography.Title>
|
justifyContent: 'space-between',
|
||||||
<Typography.Text type="secondary">
|
alignItems: 'flex-start',
|
||||||
T+30 结算:按自然月列出同城/跨城已付酒单,应付 = 酒单实付 × {ratePct}%(暂定)
|
marginBottom: 16,
|
||||||
</Typography.Text>
|
gap: 16,
|
||||||
</Space>
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
酒厂对账单
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
{canEditWineryBank ? (
|
||||||
|
<Button type="default" onClick={() => void openBankModal()}>
|
||||||
|
酒厂银行账户信息配置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<Space size="large" wrap>
|
<Space size="large" wrap>
|
||||||
<Statistic title="酒单数" value={summary.count} />
|
<Statistic title="账单数" value={summary.count} />
|
||||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -137,28 +282,30 @@ export default function WineryBillsPage() {
|
|||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
|
onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => {
|
||||||
setFilters({
|
setFilters({
|
||||||
|
status: v.status || '',
|
||||||
year: v.month ? String(v.month.year()) : '',
|
year: v.month ? String(v.month.year()) : '',
|
||||||
month: v.month ? String(v.month.month() + 1) : '',
|
month: v.month ? String(v.month.month() + 1) : '',
|
||||||
deliveryType: v.deliveryType || '',
|
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||||
|
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||||
});
|
});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="month" label="账期月">
|
<Form.Item name="status" label="状态">
|
||||||
<DatePicker picker="month" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="deliveryType" label="配送类型">
|
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
options={[
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
{ value: 'LOCAL', label: '同城' },
|
|
||||||
{ value: 'CROSS_CITY', label: '跨城' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="month" label="账期月">
|
||||||
|
<DatePicker picker="month" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="range" label="账单日">
|
||||||
|
<DatePicker.RangePicker />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
查询
|
查询
|
||||||
@@ -180,15 +327,29 @@ export default function WineryBillsPage() {
|
|||||||
导出 Excel
|
导出 Excel
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={!selectedKeys.length}
|
||||||
|
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
||||||
|
>
|
||||||
|
批量确认打款 ({selectedKeys.length})
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="orderId"
|
rowKey="id"
|
||||||
className="admin-table-nowrap"
|
className="admin-table-nowrap"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedKeys,
|
||||||
|
onChange: setSelectedKeys,
|
||||||
|
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -200,6 +361,93 @@ export default function WineryBillsPage() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Drawer title="酒厂对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={640}>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
|
订单明细
|
||||||
|
</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={detail.items ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '配送',
|
||||||
|
dataIndex: 'deliveryType',
|
||||||
|
width: 70,
|
||||||
|
render: (v) => DELIVERY_LABELS[v] || v,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '实付',
|
||||||
|
dataIndex: 'payAmount',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '酒厂应付',
|
||||||
|
dataIndex: 'wineryAmount',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付时间',
|
||||||
|
dataIndex: 'paidAt',
|
||||||
|
width: 150,
|
||||||
|
render: (v) => (v ? fmtTime(v) : '—'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="酒厂银行账户信息配置"
|
||||||
|
open={bankOpen}
|
||||||
|
onCancel={() => setBankOpen(false)}
|
||||||
|
onOk={() => void saveBank()}
|
||||||
|
confirmLoading={bankSaving}
|
||||||
|
destroyOnClose
|
||||||
|
okText="保存"
|
||||||
|
>
|
||||||
|
<Form form={bankForm} layout="vertical" disabled={bankLoading}>
|
||||||
|
<Form.Item
|
||||||
|
name="WINERY_BANK_ACCOUNT_NAME"
|
||||||
|
label="户名"
|
||||||
|
rules={[{ required: true, message: '请填写户名' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="收款账户户名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="WINERY_BANK_NAME"
|
||||||
|
label="开户银行"
|
||||||
|
rules={[{ required: true, message: '请填写开户银行' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="如:中国工商银行" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="WINERY_BANK_BRANCH" label="开户支行">
|
||||||
|
<Input placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="WINERY_BANK_ACCOUNT_NO"
|
||||||
|
label="银行账号"
|
||||||
|
rules={[{ required: true, message: '请填写银行账号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="银行卡号" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
|
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
|
||||||
Tabs, Tag, Typography, message,
|
Tabs, Tag, Typography, message,
|
||||||
@@ -15,6 +16,8 @@ type XfxConfig = {
|
|||||||
hasApiKey: boolean;
|
hasApiKey: boolean;
|
||||||
signType: string;
|
signType: string;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
source?: string;
|
||||||
|
hint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ApiResult = {
|
type ApiResult = {
|
||||||
@@ -115,8 +118,9 @@ export default function XiaofeixiaTestPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。请确保服务器已配置
|
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。凭证请在
|
||||||
{' '}<code>XIAOFEIXIA_MCH_ID</code>、<code>XIAOFEIXIA_API_KEY</code>。
|
<Link to="/fulfillment-providers">仓配管理</Link>
|
||||||
|
中配置;联调会优先使用仓配管理里启用的小飞侠承运商。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
{config && (
|
{config && (
|
||||||
@@ -132,13 +136,17 @@ export default function XiaofeixiaTestPage() {
|
|||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
{config.ready ? <Tag color="green">可调用</Tag> : <Tag color="orange">配置不完整</Tag>}
|
{config.ready ? <Tag color="green">可调用</Tag> : <Tag color="orange">配置不完整</Tag>}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源" span={3}>
|
||||||
|
{config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'}
|
||||||
|
{config.hint ? ` · ${config.hint}` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{!config.ready && (
|
{!config.ready && (
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginTop: 12 }}
|
style={{ marginTop: 12 }}
|
||||||
message="请在 server/dukang-api/.env 中配置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY 后重启 API"
|
message="请先在仓配管理中注册小飞侠并填写 API 地址、商户号与 API Key"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type CSSProperties } from 'react';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -25,6 +25,15 @@ import { request } from '../../lib/api';
|
|||||||
import { fmtTime } from '../../lib/constants';
|
import { fmtTime } from '../../lib/constants';
|
||||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||||
|
|
||||||
|
const descLabelStyle: CSSProperties = {
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
width: 108,
|
||||||
|
};
|
||||||
|
|
||||||
|
const descContentStyle: CSSProperties = {
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
};
|
||||||
|
|
||||||
async function downloadQrcode(url: string, filename: string) {
|
async function downloadQrcode(url: string, filename: string) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
@@ -72,46 +81,25 @@ export default function PromoCodeDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]} align="top">
|
||||||
<Col xs={24} lg={8}>
|
<Col flex="1 1 480px" style={{ minWidth: 0 }}>
|
||||||
<Card title="推广二维码" size="small">
|
|
||||||
{detail.qrcodeUrl ? (
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<img
|
|
||||||
src={detail.qrcodeUrl}
|
|
||||||
alt="推广二维码"
|
|
||||||
style={{ width: 200, height: 200, marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
|
||||||
<Button
|
|
||||||
block
|
|
||||||
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-qrcode.png`)}
|
|
||||||
>
|
|
||||||
下载二维码
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">暂无二维码</Typography.Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} lg={16}>
|
|
||||||
<Card
|
<Card
|
||||||
title="基础信息"
|
title="基础信息"
|
||||||
size="small"
|
size="small"
|
||||||
|
styles={{ body: { paddingTop: 12 } }}
|
||||||
extra={(
|
extra={(
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" onClick={() => {
|
<Button
|
||||||
editForm.setFieldsValue({
|
size="small"
|
||||||
name: detail.name,
|
onClick={() => {
|
||||||
scene: detail.scene,
|
editForm.setFieldsValue({
|
||||||
remark: detail.remark,
|
name: detail.name,
|
||||||
ownerUserId: detail.ownerUser?.id,
|
scene: detail.scene,
|
||||||
});
|
remark: detail.remark,
|
||||||
setEditOpen(true);
|
ownerUserId: detail.ownerUser?.id,
|
||||||
}}
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
@@ -135,7 +123,18 @@ export default function PromoCodeDetailPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
|
<Descriptions
|
||||||
|
column={2}
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
layout="horizontal"
|
||||||
|
labelStyle={descLabelStyle}
|
||||||
|
contentStyle={descContentStyle}
|
||||||
|
styles={{
|
||||||
|
label: descLabelStyle,
|
||||||
|
content: descContentStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
||||||
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
|
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
|
||||||
<Descriptions.Item label="场景">
|
<Descriptions.Item label="场景">
|
||||||
@@ -144,34 +143,81 @@ export default function PromoCodeDetailPage() {
|
|||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
|
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="二维码 ID" span={2}>
|
<Descriptions.Item label="活动 ID">
|
||||||
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
|
<Typography.Text copyable={{ text: String(detail.id) }} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{detail.id}
|
||||||
|
</Typography.Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="落地链接" span={2}>
|
<Descriptions.Item label="二维码 ID">
|
||||||
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
|
<Typography.Text copyable={{ text: detail.qrcodeId }} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{detail.qrcodeId}
|
||||||
|
</Typography.Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="OSS 地址" span={2}>
|
<Descriptions.Item label="小程序码 OSS" span={2}>
|
||||||
{detail.qrcodeUrl ? (
|
{detail.qrcodeUrl ? (
|
||||||
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
|
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis style={{ maxWidth: '100%' }}>
|
||||||
{detail.qrcodeUrl}
|
{detail.qrcodeUrl}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : '—'}
|
) : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="渠道负责人">
|
<Descriptions.Item label="渠道负责人">
|
||||||
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
|
||||||
|
</span>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
<Descriptions.Item label="创建时间">
|
||||||
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
|
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.createdAt)}</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="更新时间">
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.updatedAt)}</span>
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
<Col flex="0 0 220px">
|
||||||
|
<Card title="小程序码" size="small" styles={{ body: { textAlign: 'center', padding: 12 } }}>
|
||||||
|
{detail.qrcodeUrl ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={detail.qrcodeUrl}
|
||||||
|
alt="推广小程序码"
|
||||||
|
style={{ width: 168, height: 168, display: 'block', margin: '0 auto 8px' }}
|
||||||
|
/>
|
||||||
|
<Typography.Paragraph
|
||||||
|
type="secondary"
|
||||||
|
style={{ marginBottom: 8, fontSize: 12, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
scene={detail.id}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-wxacode.png`)}
|
||||||
|
>
|
||||||
|
下载小程序码
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无小程序码</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} />
|
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="扫码注册用户数"
|
||||||
|
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
@@ -187,11 +233,6 @@ export default function PromoCodeDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
|
||||||
<Card size="small">
|
|
||||||
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import AuthGate from './components/AuthGate';
|
import AuthGate from './components/AuthGate';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import LegalPage from './pages/LegalPage';
|
||||||
import PartnerAppRoutes from './PartnerAppRoutes';
|
import PartnerAppRoutes from './PartnerAppRoutes';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -8,6 +9,8 @@ export default function App() {
|
|||||||
<AuthGate>
|
<AuthGate>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
|
||||||
|
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||||
<Route path="/*" element={<PartnerAppRoutes />} />
|
<Route path="/*" element={<PartnerAppRoutes />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthGate>
|
</AuthGate>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
||||||
import { isSubAccount } from './lib/partnerAccess';
|
import { getPartnerNavKind, isSubAccount } from './lib/partnerAccess';
|
||||||
import TabLayout from './layouts/TabLayout';
|
import TabLayout from './layouts/TabLayout';
|
||||||
import SubAccountLayout from './layouts/SubAccountLayout';
|
import SubAccountLayout from './layouts/SubAccountLayout';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
@@ -13,9 +13,9 @@ import CenterPage from './pages/CenterPage';
|
|||||||
import PartnerMePage from './pages/PartnerMePage';
|
import PartnerMePage from './pages/PartnerMePage';
|
||||||
import BillsPage from './pages/BillsPage';
|
import BillsPage from './pages/BillsPage';
|
||||||
import SettlementPage from './pages/SettlementPage';
|
import SettlementPage from './pages/SettlementPage';
|
||||||
import ReshipPage from './pages/ReshipPage';
|
|
||||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||||
import ProxyOrderPage from './pages/ProxyOrderPage';
|
import ProxyOrderPage from './pages/ProxyOrderPage';
|
||||||
|
import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage';
|
||||||
import StaffListPage from './pages/StaffListPage';
|
import StaffListPage from './pages/StaffListPage';
|
||||||
import StaffCreatePage from './pages/StaffCreatePage';
|
import StaffCreatePage from './pages/StaffCreatePage';
|
||||||
import LeaderboardPage from './pages/LeaderboardPage';
|
import LeaderboardPage from './pages/LeaderboardPage';
|
||||||
@@ -26,7 +26,6 @@ function PrimaryRoutes() {
|
|||||||
<Route element={<TabLayout />}>
|
<Route element={<TabLayout />}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/stores" element={<StoreListPage />} />
|
<Route path="/stores" element={<StoreListPage />} />
|
||||||
<Route path="/orders" element={<OrderListPage />} />
|
|
||||||
<Route path="/center" element={<CenterPage />} />
|
<Route path="/center" element={<CenterPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||||
@@ -35,10 +34,11 @@ function PrimaryRoutes() {
|
|||||||
<Route path="/center/staff" element={<StaffListPage />} />
|
<Route path="/center/staff" element={<StaffListPage />} />
|
||||||
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||||
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
||||||
<Route path="/reshipments" element={<ReshipPage />} />
|
<Route path="/proxy-order/products" element={<ProxyOrderProductsPage />} />
|
||||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
|
<Route path="/orders" element={<OrderListPage />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -46,15 +46,30 @@ function PrimaryRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SubAccountRoutes() {
|
function SubAccountRoutes() {
|
||||||
|
const { account } = usePartnerSession();
|
||||||
|
const navKind = getPartnerNavKind(account);
|
||||||
|
const isWarehouse = navKind === 'warehouse_staff';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<SubAccountLayout />}>
|
<Route element={<SubAccountLayout navKind={navKind} />}>
|
||||||
<Route path="/stores" element={<StoreListPage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
{isWarehouse ? (
|
||||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
<Route path="/orders" element={<OrderListPage tabRoot />} />
|
||||||
|
) : (
|
||||||
|
<Route path="/stores" element={<StoreListPage />} />
|
||||||
|
)}
|
||||||
<Route path="/me" element={<PartnerMePage />} />
|
<Route path="/me" element={<PartnerMePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} />
|
{!isWarehouse && (
|
||||||
|
<>
|
||||||
|
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||||
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||||
|
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
|
import { getPartnerProfile } from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { partnerHomePath } from '../lib/partnerAccess';
|
import { partnerHomePath } from '../lib/partnerAccess';
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set(['/login']);
|
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||||
|
|
||||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
const { ready, authenticated, account } = usePartnerSession();
|
const { ready, authenticated, account } = usePartnerSession();
|
||||||
@@ -18,10 +18,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||||
const profile = getPartnerProfile();
|
|
||||||
if (profile && hasPartnerWxSession() && profile.hasWechat) {
|
|
||||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
|
||||||
}
|
|
||||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||||
import { enqueueUpload } from '../lib/upload-lock';
|
import { enqueueUpload } from '../lib/upload-lock';
|
||||||
import {
|
|
||||||
authorizePartnerWechat,
|
|
||||||
fetchClientConfig,
|
|
||||||
fetchPartnerProfile,
|
|
||||||
needsWechatAuth,
|
|
||||||
type PartnerProfile,
|
|
||||||
} from '../lib/wechat-auth';
|
|
||||||
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
|
||||||
import { toastError } from '../lib/toast';
|
import { toastError } from '../lib/toast';
|
||||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
|
||||||
|
|
||||||
type OssUploadFieldProps = {
|
type OssUploadFieldProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -22,27 +12,10 @@ type OssUploadFieldProps = {
|
|||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
/** 父级已确认微信授权时可跳过检查 */
|
|
||||||
wechatReady?: boolean;
|
|
||||||
onWechatReadyChange?: (ready: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MAX_MB = 10;
|
const DEFAULT_MAX_MB = 10;
|
||||||
|
|
||||||
function formatWechatUploadError(e: unknown): string {
|
|
||||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
|
||||||
const formatted = formatChooseImageFailMessage(msg);
|
|
||||||
if (formatted) return formatted;
|
|
||||||
if (/invalid signature/i.test(msg)) {
|
|
||||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptsImages(accept: string) {
|
|
||||||
return accept.includes('image');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OssUploadField({
|
export default function OssUploadField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -52,15 +25,10 @@ export default function OssUploadField({
|
|||||||
wide,
|
wide,
|
||||||
compact,
|
compact,
|
||||||
label,
|
label,
|
||||||
wechatReady,
|
|
||||||
onWechatReadyChange,
|
|
||||||
}: OssUploadFieldProps) {
|
}: OssUploadFieldProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [authorizing, setAuthorizing] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [profile, setProfile] = useState<PartnerProfile | null>(null);
|
|
||||||
const [clientConfig, setClientConfig] = useState<ClientRuntimeConfig | null>(null);
|
|
||||||
|
|
||||||
function showUploadError(text: string) {
|
function showUploadError(text: string) {
|
||||||
setError(text);
|
setError(text);
|
||||||
@@ -69,35 +37,6 @@ export default function OssUploadField({
|
|||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
||||||
const inWechat = isWechatEnv();
|
|
||||||
const useWechatPicker =
|
|
||||||
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
|
|
||||||
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
|
|
||||||
const onWechatReadyChangeRef = useRef(onWechatReadyChange);
|
|
||||||
onWechatReadyChangeRef.current = onWechatReadyChange;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!useWechatPicker) return;
|
|
||||||
void fetchClientConfig()
|
|
||||||
.then(setClientConfig)
|
|
||||||
.catch(() => {
|
|
||||||
/* 未登录等场景由上传接口报错 */
|
|
||||||
});
|
|
||||||
if (wechatReady === true) return;
|
|
||||||
void fetchPartnerProfile()
|
|
||||||
.then(setProfile)
|
|
||||||
.catch(() => {
|
|
||||||
/* 未登录等场景由上传接口报错 */
|
|
||||||
});
|
|
||||||
}, [useWechatPicker, wechatReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!useWechatPicker || needsAuth) return;
|
|
||||||
weixinSdk.reset();
|
|
||||||
void weixinSdk.init().catch(() => {
|
|
||||||
/* 点击上传时会再次初始化 */
|
|
||||||
});
|
|
||||||
}, [useWechatPicker, needsAuth]);
|
|
||||||
|
|
||||||
async function persistUpload(file: File) {
|
async function persistUpload(file: File) {
|
||||||
if (!file.size) {
|
if (!file.size) {
|
||||||
@@ -125,112 +64,38 @@ export default function OssUploadField({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startWechatAuth() {
|
function pickFile() {
|
||||||
setAuthorizing(true);
|
if (uploading) return;
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
|
||||||
const result = await authorizePartnerWechat();
|
|
||||||
if (result) {
|
|
||||||
const me = await fetchPartnerProfile();
|
|
||||||
setProfile(me);
|
|
||||||
if (me.hasWechat) onWechatReadyChangeRef.current?.(true);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const text = e instanceof Error ? e.message : '微信授权失败';
|
|
||||||
showUploadError(text);
|
|
||||||
} finally {
|
|
||||||
setAuthorizing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickWechatImage() {
|
|
||||||
setUploading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
weixinSdk.reset();
|
|
||||||
await weixinSdk.init();
|
|
||||||
// 选图 + 上传须在同一个队列任务内完成,避免嵌套 enqueueUpload 死锁
|
|
||||||
await enqueueUpload(async () => {
|
|
||||||
const files = await weixinSdk.chooseImages({
|
|
||||||
count: 1,
|
|
||||||
sourceType: ['album', 'camera'],
|
|
||||||
});
|
|
||||||
if (!files?.[0]) return;
|
|
||||||
await persistUpload(files[0]);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
|
||||||
if (/cancel/i.test(msg)) return;
|
|
||||||
showUploadError(formatWechatUploadError(e));
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickFile() {
|
|
||||||
if (uploading || authorizing) return;
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
if (useWechatPicker && needsAuth) {
|
|
||||||
const text = '请先完成微信授权后再上传照片';
|
|
||||||
showUploadError(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useWechatPicker) {
|
|
||||||
try {
|
|
||||||
await pickWechatImage();
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
|
||||||
if (/cancel/i.test(msg)) return;
|
|
||||||
showUploadError(formatWechatUploadError(e));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImage = mediaType === 'IMAGE' && value;
|
const isImage = mediaType === 'IMAGE' && value;
|
||||||
const isFile = mediaType === 'FILE' && value;
|
const isFile = mediaType === 'FILE' && value;
|
||||||
const busy = uploading || authorizing;
|
const busy = uploading;
|
||||||
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
|
const pickerLabel = label ?? (mediaType === 'IMAGE' ? '从系统相册选择' : '点击上传');
|
||||||
|
|
||||||
const triggerProps = {
|
const triggerProps = {
|
||||||
type: 'button' as const,
|
type: 'button' as const,
|
||||||
disabled: busy || needsAuth,
|
disabled: busy,
|
||||||
onClick: () => void pickFile(),
|
onClick: pickFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-oss-upload">
|
<div className="partner-oss-upload">
|
||||||
{needsAuth && (
|
<input
|
||||||
<div className="partner-wechat-auth-hint" role="status">
|
ref={inputRef}
|
||||||
<p className="body-md">上传照片需先完成微信授权绑定</p>
|
type="file"
|
||||||
<button
|
accept={resolvedAccept}
|
||||||
type="button"
|
className="partner-oss-upload-input"
|
||||||
className="partner-btn-outline"
|
disabled={busy}
|
||||||
style={{ marginTop: 8, width: '100%' }}
|
onChange={(e) => {
|
||||||
disabled={authorizing}
|
const file = e.target.files?.[0];
|
||||||
onClick={() => void startWechatAuth()}
|
if (file) {
|
||||||
>
|
void uploadSelectedFile(file);
|
||||||
{authorizing ? '跳转授权中…' : '微信授权绑定'}
|
}
|
||||||
</button>
|
}}
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
{!useWechatPicker && (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept={resolvedAccept}
|
|
||||||
className="partner-oss-upload-input"
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) void uploadSelectedFile(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
||||||
<img src={value} alt={label ?? '已上传'} />
|
<img src={value} alt={label ?? '已上传'} />
|
||||||
@@ -255,10 +120,10 @@ export default function OssUploadField({
|
|||||||
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
|
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
|
||||||
{busy ? 'hourglass_top' : 'add_a_photo'}
|
{busy ? 'hourglass_top' : 'photo_library'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||||
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel}
|
{uploading ? '上传中…' : pickerLabel}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import {
|
||||||
|
placeToPicked,
|
||||||
|
type LbsPlaceItem,
|
||||||
|
type TencentPickedLocation,
|
||||||
|
} from '../lib/tencentLocPicker';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onPick: (loc: TencentPickedLocation) => void;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
region?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TencentLocPickerOverlay({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onPick,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
region,
|
||||||
|
}: Props) {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [items, setItems] = useState<LbsPlaceItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
|
||||||
|
const [hint, setHint] = useState('输入地点名称搜索');
|
||||||
|
const seqRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setKeyword('');
|
||||||
|
setItems([]);
|
||||||
|
setPending(null);
|
||||||
|
setHint('输入地点名称搜索');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lat = latitude != null ? Number(latitude) : NaN;
|
||||||
|
const lng = longitude != null ? Number(longitude) : NaN;
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
|
||||||
|
void loadNearby(lat, lng);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function loadNearby(lat: number, lng: number) {
|
||||||
|
const seq = ++seqRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setHint('正在加载附近地点…');
|
||||||
|
try {
|
||||||
|
const res = await request<{ items: LbsPlaceItem[] }>(
|
||||||
|
'PARTNER_H5',
|
||||||
|
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
|
||||||
|
{ silent: true },
|
||||||
|
);
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setHint(res.items?.length ? `附近 ${res.items.length} 个地点` : '附近暂无地点,请搜索');
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setItems([]);
|
||||||
|
setHint(e instanceof Error ? e.message : '加载附近地点失败');
|
||||||
|
} finally {
|
||||||
|
if (seq === seqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
const trimmed = keyword.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setHint('请输入搜索关键词');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seq = ++seqRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setHint('搜索中…');
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ keyword: trimmed });
|
||||||
|
if (region?.trim()) params.set('region', region.trim());
|
||||||
|
const lat = latitude != null ? Number(latitude) : NaN;
|
||||||
|
const lng = longitude != null ? Number(longitude) : NaN;
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||||
|
params.set('lat', String(lat));
|
||||||
|
params.set('lng', String(lng));
|
||||||
|
}
|
||||||
|
const res = await request<{ items: LbsPlaceItem[] }>(
|
||||||
|
'PARTNER_H5',
|
||||||
|
`/common/lbs/suggest?${params.toString()}`,
|
||||||
|
{ silent: true },
|
||||||
|
);
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
setHint(res.items?.length ? `找到 ${res.items.length} 个结果` : '无匹配结果');
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setItems([]);
|
||||||
|
setHint(e instanceof Error ? e.message : '搜索失败');
|
||||||
|
} finally {
|
||||||
|
if (seq === seqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBrowserLocation() {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setHint('当前浏览器不支持定位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const lat = pos.coords.latitude;
|
||||||
|
const lng = pos.coords.longitude;
|
||||||
|
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
|
||||||
|
void loadNearby(lat, lng);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setLoading(false);
|
||||||
|
setHint('定位失败,请检查定位权限');
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 12000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
function confirmPick() {
|
||||||
|
if (!pending) return;
|
||||||
|
onPick(pending);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||||
|
flexShrink: 0,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" className="partner-btn-outline" style={{ padding: '6px 12px' }} onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
<span style={{ fontWeight: 600 }}>地图选点</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
style={{ padding: '6px 12px', opacity: pending ? 1 : 0.45, width: 'auto' }}
|
||||||
|
disabled={!pending}
|
||||||
|
onClick={confirmPick}
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(0,0,0,0.04)', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void runSearch();
|
||||||
|
}}
|
||||||
|
placeholder="输入小区 / 写字楼 / 门店名称"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid rgba(0,0,0,0.12)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
style={{ width: 'auto', padding: '8px 14px' }}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void runSearch()}
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ padding: '6px 12px' }}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={useBrowserLocation}
|
||||||
|
>
|
||||||
|
定位当前位置
|
||||||
|
</button>
|
||||||
|
<span className="label-md text-muted">{hint}</span>
|
||||||
|
</div>
|
||||||
|
{pending ? (
|
||||||
|
<p className="label-md text-muted" style={{ margin: '8px 0 0' }}>
|
||||||
|
已选 {pending.latitude.toFixed(6)}, {pending.longitude.toFixed(6)}
|
||||||
|
{pending.name ? ` · ${pending.name}` : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||||
|
{loading && !items.length ? (
|
||||||
|
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
加载中…
|
||||||
|
</p>
|
||||||
|
) : items.length ? (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const active =
|
||||||
|
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
|
||||||
|
return (
|
||||||
|
<li key={`${item.id}-${item.latitude}-${item.longitude}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPending(placeToPicked(item))}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: 0,
|
||||||
|
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||||
|
background: active ? 'rgba(166, 29, 36, 0.06)' : '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 15 }}>{item.title}</div>
|
||||||
|
<div className="label-md text-muted" style={{ marginTop: 4 }}>
|
||||||
|
{item.address}
|
||||||
|
</div>
|
||||||
|
<div className="label-md text-muted" style={{ marginTop: 2, fontSize: 12 }}>
|
||||||
|
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,7 +50,13 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
|
|||||||
staffRole: profile.staffRole,
|
staffRole: profile.staffRole,
|
||||||
permissions: profile.permissions,
|
permissions: profile.permissions,
|
||||||
primaryAccountId: profile.primaryAccountId,
|
primaryAccountId: profile.primaryAccountId,
|
||||||
|
primaryPhone: profile.primaryPhone,
|
||||||
|
primaryName: profile.primaryName,
|
||||||
hasWechat: profile.hasWechat,
|
hasWechat: profile.hasWechat,
|
||||||
|
wxNickname: profile.wxNickname,
|
||||||
|
wxAvatarUrl: profile.wxAvatarUrl,
|
||||||
|
managedWarehouseId: profile.managedWarehouseId,
|
||||||
|
hasWarehouseAccess: profile.hasWarehouseAccess,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
import type { PartnerNavKind } from '../lib/partnerAccess';
|
||||||
|
|
||||||
const TABS = [
|
const STORE_STAFF_TABS = [
|
||||||
{ to: '/stores', end: true, icon: 'store', label: '门店管理' },
|
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||||
{ to: '/stores/new', icon: 'add_business', label: '录入门店' },
|
{ to: '/stores', icon: 'store', label: '门店管理' },
|
||||||
{ to: '/me', icon: 'person', label: '我的' },
|
{ to: '/me', icon: 'person', label: '个人中心' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function SubAccountLayout() {
|
const WAREHOUSE_STAFF_TABS = [
|
||||||
|
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||||
|
{ to: '/orders', icon: 'receipt_long', label: '订单管理' },
|
||||||
|
{ to: '/me', icon: 'person', label: '个人中心' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SubAccountLayoutProps = {
|
||||||
|
navKind: PartnerNavKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SubAccountLayout({ navKind }: SubAccountLayoutProps) {
|
||||||
|
const tabs = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_TABS : STORE_STAFF_TABS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<nav className="app-tabbar">
|
<nav className="app-tabbar">
|
||||||
{TABS.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={tab.to}
|
key={tab.to}
|
||||||
to={tab.to}
|
to={tab.to}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const PARTNER_PROFILE = 'partnerProfile';
|
|||||||
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
|
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
|
||||||
export const PARTNER_WX_BOUND = 'partnerWxBound';
|
export const PARTNER_WX_BOUND = 'partnerWxBound';
|
||||||
|
|
||||||
/** 微信验证通过后的免登录时长 */
|
/** 手机号或微信验证通过后的免登录时长 */
|
||||||
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||||
@@ -24,7 +24,20 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
|
|||||||
|
|
||||||
export type PartnerSessionProfile = Pick<
|
export type PartnerSessionProfile = Pick<
|
||||||
PartnerMe,
|
PartnerMe,
|
||||||
'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'hasWechat'
|
| 'id'
|
||||||
|
| 'name'
|
||||||
|
| 'phone'
|
||||||
|
| 'companyName'
|
||||||
|
| 'isPrimary'
|
||||||
|
| 'permissions'
|
||||||
|
| 'primaryAccountId'
|
||||||
|
| 'primaryPhone'
|
||||||
|
| 'primaryName'
|
||||||
|
| 'hasWechat'
|
||||||
|
| 'wxNickname'
|
||||||
|
| 'wxAvatarUrl'
|
||||||
|
| 'managedWarehouseId'
|
||||||
|
| 'hasWarehouseAccess'
|
||||||
> & {
|
> & {
|
||||||
staffRole?: PartnerStaffRole;
|
staffRole?: PartnerStaffRole;
|
||||||
};
|
};
|
||||||
@@ -64,7 +77,7 @@ export function isPartnerSessionExpired() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function touchPartnerSession() {
|
export function touchPartnerSession() {
|
||||||
if (!hasPartnerWxSession()) return;
|
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +90,16 @@ export function saveAuth(data: PartnerSessionPayload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||||||
|
export function saveRememberedSession(data: PartnerSessionPayload) {
|
||||||
|
saveAuth(data);
|
||||||
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||||
|
}
|
||||||
|
|
||||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||||
export function saveWechatSession(data: PartnerSessionPayload) {
|
export function saveWechatSession(data: PartnerSessionPayload) {
|
||||||
saveAuth(data);
|
saveRememberedSession(data);
|
||||||
localStorage.setItem(PARTNER_WX_BOUND, '1');
|
localStorage.setItem(PARTNER_WX_BOUND, '1');
|
||||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||||
@@ -109,7 +127,13 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
|
|||||||
staffRole: me.staffRole ?? undefined,
|
staffRole: me.staffRole ?? undefined,
|
||||||
permissions: me.permissions,
|
permissions: me.permissions,
|
||||||
primaryAccountId: me.primaryAccountId,
|
primaryAccountId: me.primaryAccountId,
|
||||||
|
primaryPhone: me.primaryPhone,
|
||||||
|
primaryName: me.primaryName,
|
||||||
hasWechat: me.hasWechat,
|
hasWechat: me.hasWechat,
|
||||||
|
wxNickname: me.wxNickname,
|
||||||
|
wxAvatarUrl: me.wxAvatarUrl,
|
||||||
|
managedWarehouseId: me.managedWarehouseId,
|
||||||
|
hasWarehouseAccess: me.hasWarehouseAccess,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** 合伙人端客服热线(可后续改为环境变量) */
|
||||||
|
export const PARTNER_SUPPORT_PHONE = '400-000-0000';
|
||||||
|
|
||||||
|
export function dialPhone(phone: string) {
|
||||||
|
const normalized = phone.replace(/\s+/g, '');
|
||||||
|
if (!normalized) return false;
|
||||||
|
window.location.href = `tel:${normalized}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contactSupport() {
|
||||||
|
return dialPhone(PARTNER_SUPPORT_PHONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contactPartnerPhone(phone?: string) {
|
||||||
|
return dialPhone(phone ?? '');
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'tota
|
|||||||
return request<PartnerLeaderboardResponse>(
|
return request<PartnerLeaderboardResponse>(
|
||||||
'PARTNER_H5',
|
'PARTNER_H5',
|
||||||
`/partner/dashboard/leaderboard?period=${period}`,
|
`/partner/dashboard/leaderboard?period=${period}`,
|
||||||
|
{ silent: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
|
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
|
||||||
|
|
||||||
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
|
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
|
||||||
return account?.isPrimary !== false;
|
return account?.isPrimary !== false;
|
||||||
}
|
}
|
||||||
@@ -17,15 +19,88 @@ export function hasPartnerPermission(
|
|||||||
return account.permissions?.includes(permission) ?? false;
|
return account.permissions?.includes(permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
export function hasAnyPartnerPermission(
|
||||||
return isSubAccount(account) ? '/stores/new?step=1' : '/';
|
account: PartnerMe | null | undefined,
|
||||||
|
permissions: PartnerPermissionKey[],
|
||||||
|
): boolean {
|
||||||
|
return permissions.some((p) => hasPartnerPermission(account, p));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/me', '/login'];
|
/** 与后端 GET /partner/dashboard 权限点一致 */
|
||||||
|
export function canAccessPartnerDashboard(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasAnyPartnerPermission(account, [
|
||||||
|
'store:create',
|
||||||
|
'store:manage',
|
||||||
|
'order:view',
|
||||||
|
'warehouse:manage',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
export function isSubAccountPath(pathname: string): boolean {
|
/** 门店列表/拓店统计所需权限 */
|
||||||
|
export function canAccessPartnerStores(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
|
||||||
|
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
|
||||||
|
if (!account) return false;
|
||||||
|
if (isPrimaryAccount(account)) return true;
|
||||||
|
if (isWarehouseStaff(account)) return false;
|
||||||
|
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
|
||||||
|
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
|
||||||
|
return !Array.isArray(account.permissions) || account.permissions.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 录入新店 */
|
||||||
|
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
|
||||||
|
if (!account) return false;
|
||||||
|
if (isPrimaryAccount(account)) return true;
|
||||||
|
if (isWarehouseStaff(account)) return false;
|
||||||
|
if (hasPartnerPermission(account, 'store:create')) return true;
|
||||||
|
return !Array.isArray(account.permissions) || account.permissions.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 GET /partner/orders 权限点一致 */
|
||||||
|
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
|
||||||
|
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
|
||||||
|
if (!account || isPrimaryAccount(account)) return false;
|
||||||
|
if (hasPartnerPermission(account, 'warehouse:manage')) return true;
|
||||||
|
const hasStore =
|
||||||
|
hasPartnerPermission(account, 'store:create') || hasPartnerPermission(account, 'store:manage');
|
||||||
|
if (hasPartnerPermission(account, 'order:view') && !hasStore) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPartnerNavKind(account: PartnerMe | null | undefined): PartnerNavKind {
|
||||||
|
if (!account || isPrimaryAccount(account)) return 'primary';
|
||||||
|
if (isWarehouseStaff(account)) return 'warehouse_staff';
|
||||||
|
return 'store_staff';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已配置管仓:主账号看 hasWarehouseAccess;子账号还需有仓库相关权限 */
|
||||||
|
export function hasWarehouseAccess(account: PartnerMe | null | undefined): boolean {
|
||||||
|
if (!account) return false;
|
||||||
|
if (account.hasWarehouseAccess === false) return false;
|
||||||
|
if (account.hasWarehouseAccess === true) return true;
|
||||||
|
// 旧缓存无字段时保守:主账号未知视为无,避免误展示全城订单
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
|
||||||
|
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
|
||||||
|
|
||||||
|
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
|
||||||
if (pathname === '/login') return true;
|
if (pathname === '/login') return true;
|
||||||
return SUB_ACCOUNT_ALLOWED_PREFIXES.some(
|
const prefixes = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_PREFIXES : STORE_STAFF_PREFIXES;
|
||||||
|
return prefixes.some(
|
||||||
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
|
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import {
|
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||||
PartnerStaffRole,
|
import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
|
||||||
type CreatePartnerStaffRequest,
|
|
||||||
type PartnerStaffItem,
|
|
||||||
type UpdatePartnerStaffRequest,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { request } from './api';
|
import { request } from './api';
|
||||||
|
|
||||||
export function listPartnerStaff() {
|
export function listPartnerStaff() {
|
||||||
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
|||||||
name: body.name,
|
name: body.name,
|
||||||
phone: body.phone,
|
phone: body.phone,
|
||||||
smsCode: body.smsCode,
|
smsCode: body.smsCode,
|
||||||
staffRole: PartnerStaffRole.INTERNAL,
|
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||||
|
permissions: body.permissions?.length
|
||||||
|
? body.permissions
|
||||||
|
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,23 @@ export type StoreDraftForm = {
|
|||||||
district: string;
|
district: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
storeSmsCode: string;
|
|
||||||
address: string;
|
address: string;
|
||||||
|
/** 门店坐标(定位或地理编码) */
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
openTime: string;
|
||||||
|
closeTime: string;
|
||||||
|
/** 是否启用第二段营业时间 */
|
||||||
|
dualHours: boolean;
|
||||||
|
openTime2: string;
|
||||||
|
closeTime2: string;
|
||||||
|
/** 人均费用(选填) */
|
||||||
|
avgPrice: string;
|
||||||
|
categoryParentId: string;
|
||||||
|
categoryId: string;
|
||||||
intro: string;
|
intro: string;
|
||||||
|
/** 好客权益券使用规则 */
|
||||||
|
benefitUsageRule: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
envPhotoUrls: string[];
|
envPhotoUrls: string[];
|
||||||
contractUrl: string;
|
contractUrl: string;
|
||||||
@@ -35,9 +49,19 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
cityId: '',
|
cityId: '',
|
||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
storeSmsCode: '',
|
|
||||||
address: '',
|
address: '',
|
||||||
|
latitude: '',
|
||||||
|
longitude: '',
|
||||||
|
openTime: '10:00',
|
||||||
|
closeTime: '22:00',
|
||||||
|
dualHours: false,
|
||||||
|
openTime2: '17:00',
|
||||||
|
closeTime2: '21:00',
|
||||||
|
avgPrice: '',
|
||||||
|
categoryParentId: '',
|
||||||
|
categoryId: '',
|
||||||
intro: '',
|
intro: '',
|
||||||
|
benefitUsageRule: '',
|
||||||
coverUrl: '',
|
coverUrl: '',
|
||||||
envPhotoUrls: ['', '', ''],
|
envPhotoUrls: ['', '', ''],
|
||||||
contractUrl: '',
|
contractUrl: '',
|
||||||
@@ -46,35 +70,38 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
bankBranch: '',
|
bankBranch: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function normalizeStringArray(raw: unknown, length: number): string[] {
|
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
|
const PHONE_RE = /^1[3-9]\d{9}$/;
|
||||||
const items = raw.map((item) => String(item ?? ''));
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
while (items.length < length) items.push('');
|
|
||||||
return items.slice(0, length);
|
function timeToMinutes(hhmm: string): number {
|
||||||
|
const [h, m] = hhmm.split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
||||||
|
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
||||||
|
while (arr.length < minLen) arr.push('');
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
|
||||||
const base = defaultStoreForm();
|
const base = defaultStoreForm();
|
||||||
const regionCodes = Array.isArray(raw.regionCodes) && raw.regionCodes.length >= 3
|
if (!raw) return base;
|
||||||
? raw.regionCodes.map((code) => String(code))
|
|
||||||
: base.regionCodes;
|
|
||||||
return {
|
return {
|
||||||
regionCodes,
|
...base,
|
||||||
|
...raw,
|
||||||
|
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
||||||
cityId: String(raw.cityId ?? base.cityId),
|
cityId: String(raw.cityId ?? base.cityId),
|
||||||
province: String(raw.province ?? base.province),
|
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
|
||||||
city: String(raw.city ?? base.city),
|
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
|
||||||
district: String(raw.district ?? base.district),
|
openTime: String(raw.openTime ?? base.openTime),
|
||||||
name: String(raw.name ?? base.name),
|
closeTime: String(raw.closeTime ?? base.closeTime),
|
||||||
phone: String(raw.phone ?? base.phone),
|
dualHours: Boolean(raw.dualHours),
|
||||||
storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode),
|
openTime2: String(raw.openTime2 ?? base.openTime2),
|
||||||
address: String(raw.address ?? base.address),
|
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||||
intro: String(raw.intro ?? base.intro),
|
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||||
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
|
||||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||||
contractUrl: String(raw.contractUrl ?? base.contractUrl),
|
|
||||||
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
|
|
||||||
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
|
|
||||||
bankBranch: String(raw.bankBranch ?? base.bankBranch),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +117,11 @@ export function loadStoreDraft(accountId?: string): StoreDraft | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
const parsed = JSON.parse(raw) as StoreDraft;
|
||||||
if (parsed.form && typeof parsed.form === 'object') {
|
return {
|
||||||
return {
|
step: Number(parsed.step) || 0,
|
||||||
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)),
|
form: normalizeStoreDraftForm(parsed.form),
|
||||||
form: normalizeForm(parsed.form as Record<string, unknown>),
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
return { step: 1, form: normalizeForm(parsed) };
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -117,26 +141,58 @@ export function clearAllStoreDrafts(accountId?: string) {
|
|||||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PHONE_RE = /^1\d{10}$/;
|
|
||||||
const BANK_RE = /^\d{16,19}$/;
|
|
||||||
|
|
||||||
export function validateStoreStep1(
|
export function validateStoreStep1(
|
||||||
form: Pick<
|
form: Pick<
|
||||||
StoreDraftForm,
|
StoreDraftForm,
|
||||||
'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro'
|
| 'regionCodes'
|
||||||
|
| 'cityId'
|
||||||
|
| 'name'
|
||||||
|
| 'address'
|
||||||
|
| 'openTime'
|
||||||
|
| 'closeTime'
|
||||||
|
| 'dualHours'
|
||||||
|
| 'openTime2'
|
||||||
|
| 'closeTime2'
|
||||||
|
| 'avgPrice'
|
||||||
|
| 'categoryId'
|
||||||
|
| 'intro'
|
||||||
|
| 'benefitUsageRule'
|
||||||
>,
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||||
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
|
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
|
||||||
if (!form.name.trim()) return '请填写门店名称';
|
if (!form.name.trim()) return '请填写门店名称';
|
||||||
if (!form.phone.trim()) return '请填写联系电话';
|
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
|
||||||
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
|
|
||||||
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
|
|
||||||
if (!form.address.trim()) return '请填写详细地址';
|
if (!form.address.trim()) return '请填写详细地址';
|
||||||
|
if (!form.openTime.trim()) return '请填写营业开始时间';
|
||||||
|
if (!TIME_RE.test(form.openTime.trim())) return '营业开始时间格式须为 HH:MM';
|
||||||
|
if (!form.closeTime.trim()) return '请填写营业结束时间';
|
||||||
|
if (!TIME_RE.test(form.closeTime.trim())) return '营业结束时间格式须为 HH:MM';
|
||||||
|
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
|
||||||
|
return '营业结束时间须晚于开始时间';
|
||||||
|
}
|
||||||
|
if (form.dualHours) {
|
||||||
|
if (!form.openTime2.trim() || !form.closeTime2.trim()) return '请完整填写第二段营业时间';
|
||||||
|
if (!TIME_RE.test(form.openTime2.trim()) || !TIME_RE.test(form.closeTime2.trim())) {
|
||||||
|
return '第二段时间格式须为 HH:MM';
|
||||||
|
}
|
||||||
|
if (timeToMinutes(form.openTime2.trim()) >= timeToMinutes(form.closeTime2.trim())) {
|
||||||
|
return '第二段结束时间须晚于开始时间';
|
||||||
|
}
|
||||||
|
if (timeToMinutes(form.closeTime.trim()) >= timeToMinutes(form.openTime2.trim())) {
|
||||||
|
return '第二段开始时间须晚于第一段结束时间';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (form.avgPrice.trim()) {
|
||||||
|
const n = Number(form.avgPrice);
|
||||||
|
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
|
||||||
|
}
|
||||||
|
if (!form.categoryId.trim()) return '请选择店铺类型';
|
||||||
if (form.intro.trim()) {
|
if (form.intro.trim()) {
|
||||||
const len = form.intro.trim().length;
|
const len = form.intro.trim().length;
|
||||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
|
||||||
|
}
|
||||||
|
if (form.benefitUsageRule.trim().length > 1000) {
|
||||||
|
return '好客权益券使用规则最多 1000 字';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -158,11 +214,16 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateStoreStep3(
|
export function validateStoreStep3(
|
||||||
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
form: Pick<
|
||||||
|
StoreDraftForm,
|
||||||
|
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
|
||||||
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
||||||
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
||||||
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
||||||
if (!form.bankBranch.trim()) return '请填写开户支行';
|
if (!form.bankBranch.trim()) return '请填写开户支行';
|
||||||
|
if (!form.phone.trim()) return '请填写联系电话';
|
||||||
|
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { weixinSdk } from './weixin';
|
||||||
|
|
||||||
|
export type StorePosition = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 获取当前 GPS 坐标(微信 JSSDK / 浏览器 Geolocation) */
|
||||||
|
export async function locateStorePosition(): Promise<StorePosition> {
|
||||||
|
const outcome = await weixinSdk.getLocationDetailed();
|
||||||
|
if (!outcome.location) {
|
||||||
|
throw new Error(outcome.errMsg || '定位失败,请允许位置权限后重试');
|
||||||
|
}
|
||||||
|
const { latitude, longitude } = outcome.location;
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
throw new Error('定位结果无效');
|
||||||
|
}
|
||||||
|
return { latitude, longitude };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStoreCoords(lat?: string | number | null, lng?: string | number | null): string {
|
||||||
|
const a = lat != null && lat !== '' ? Number(lat) : NaN;
|
||||||
|
const b = lng != null && lng !== '' ? Number(lng) : NaN;
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
|
||||||
|
return `${a.toFixed(6)}, ${b.toFixed(6)}`;
|
||||||
|
}
|
||||||
@@ -8,12 +8,4 @@ export function checkStorePhoneAvailable(phone: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendStorePhoneSms(phone: string) {
|
|
||||||
return request<{ ok: boolean; maskedPhone: string }>('PARTNER_H5', '/partner/stores/send-phone-sms', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: phone.trim() }),
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { PartnerStorePhoneAvailableResponse };
|
export type { PartnerStorePhoneAvailableResponse };
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
|
|||||||
export function storeStatusLabel(status: string): string {
|
export function storeStatusLabel(status: string): string {
|
||||||
const s = String(status).toUpperCase();
|
const s = String(status).toUpperCase();
|
||||||
if (s === 'OPEN') return '营业中';
|
if (s === 'OPEN') return '营业中';
|
||||||
if (s === 'PAUSED') return '暂时闭店';
|
if (s === 'PAUSED') return '临时闭店';
|
||||||
if (s === 'CLOSED') return '关闭';
|
if (s === 'CLOSED') return '永久关闭';
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type TencentPickedLocation = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
address?: string;
|
||||||
|
name?: string;
|
||||||
|
cityname?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LbsPlaceItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
address: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
city?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
|
||||||
|
return {
|
||||||
|
latitude: item.latitude,
|
||||||
|
longitude: item.longitude,
|
||||||
|
address: item.address || undefined,
|
||||||
|
name: item.title || undefined,
|
||||||
|
cityname: item.city || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export type PartnerProfile = {
|
|||||||
phone: string;
|
phone: string;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
hasWechat?: boolean;
|
hasWechat?: boolean;
|
||||||
|
wxNickname?: string | null;
|
||||||
|
wxAvatarUrl?: string | null;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,6 +84,8 @@ export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessio
|
|||||||
companyName: partner.companyName ? String(partner.companyName) : undefined,
|
companyName: partner.companyName ? String(partner.companyName) : undefined,
|
||||||
isPrimary: partner.isPrimary !== false,
|
isPrimary: partner.isPrimary !== false,
|
||||||
hasWechat: true,
|
hasWechat: true,
|
||||||
|
wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined,
|
||||||
|
wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import App from './App';
|
|||||||
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
|
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
|
||||||
import { PartnerToastProvider } from './contexts/PartnerToastContext';
|
import { PartnerToastProvider } from './contexts/PartnerToastContext';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
import './styles/legal.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -1,42 +1,114 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function billStatusLabel(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'AWAITING_CONFIRM': return '待确认';
|
||||||
|
case 'UNPAID': return '未打款';
|
||||||
|
case 'PAID': return '已打款';
|
||||||
|
case 'REJECTED': return '已驳回';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canConfirm(status: string) {
|
||||||
|
return status === 'AWAITING_CONFIRM';
|
||||||
|
}
|
||||||
|
|
||||||
export default function BillsPage() {
|
export default function BillsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
async function loadBills() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills');
|
||||||
|
setBills(Array.isArray(list) ? list : []);
|
||||||
|
} catch {
|
||||||
|
setBills([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
void loadBills();
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const billId = searchParams.get('id');
|
const billId = searchParams.get('id');
|
||||||
|
const actionable = useMemo(
|
||||||
|
() => bills.filter((b) => canConfirm(b.status)),
|
||||||
|
[bills],
|
||||||
|
);
|
||||||
const bill = billId
|
const bill = billId
|
||||||
? bills.find((b) => String(b.id) === billId) ?? bills[0]
|
? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
|
||||||
: bills[0];
|
: actionable[0] ?? bills[0];
|
||||||
|
|
||||||
function confirmBill() {
|
const status = String(bill?.status || '');
|
||||||
if (!confirmed) return;
|
const showApplyForm = !!bill && canConfirm(status);
|
||||||
|
const isUnpaid = status === 'UNPAID';
|
||||||
|
const isRejected = status === 'REJECTED';
|
||||||
|
const isPaid = status === 'PAID';
|
||||||
|
|
||||||
|
function askConfirm(ids: string[]) {
|
||||||
|
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void doConfirm(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doConfirm(ids: string[]) {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setTimeout(() => {
|
try {
|
||||||
|
if (ids.length === 1) {
|
||||||
|
await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||||
|
} else {
|
||||||
|
await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toastSuccess('已确认,等待总部打款');
|
||||||
|
setConfirmed(false);
|
||||||
|
setSelectedIds([]);
|
||||||
|
await loadBills();
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '提交失败');
|
||||||
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
alert('申请已提交,请耐心等待打款审核。(preV1 演示)');
|
}
|
||||||
}, 1500);
|
}
|
||||||
|
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-bills-page">
|
<PullToRefresh onRefresh={loadBills} className="partner-bills-page">
|
||||||
<PageHeader title="待确认账单" onBack={() => navigate('/center/settlement')} />
|
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
|
||||||
|
|
||||||
<div className="partner-bill-stepper">
|
<div className="partner-bill-stepper">
|
||||||
<div className="partner-stepper-inner">
|
<div className="partner-stepper-inner">
|
||||||
<div className="partner-stepper-line" aria-hidden>
|
<div className="partner-stepper-line" aria-hidden>
|
||||||
<div className="partner-stepper-line-fill" style={{ width: '50%' }} />
|
<div
|
||||||
|
className="partner-stepper-line-fill"
|
||||||
|
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-step">
|
<div className="partner-step">
|
||||||
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
|
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
|
||||||
@@ -45,17 +117,28 @@ export default function BillsPage() {
|
|||||||
<span className="partner-step-label partner-step-label--active">数据核算</span>
|
<span className="partner-step-label partner-step-label--active">数据核算</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-step">
|
<div className="partner-step">
|
||||||
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--active">2</div>
|
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
|
||||||
|
{showApplyForm && !confirmed ? '2' : (
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="partner-step-label partner-step-label--active">账单确认</span>
|
<span className="partner-step-label partner-step-label--active">账单确认</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-step">
|
<div className="partner-step">
|
||||||
<div className="partner-step-circle partner-step-circle--sm">3</div>
|
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}>
|
||||||
<span className="partner-step-label">申请打款</span>
|
{isPaid ? (
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||||
|
) : '3'}
|
||||||
|
</div>
|
||||||
|
<span className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
|
||||||
|
{isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!bill && <div className="empty">暂无账单</div>}
|
{loading && <div className="empty">加载中…</div>}
|
||||||
|
{!loading && !bill && <div className="empty">暂无待确认账单</div>}
|
||||||
|
|
||||||
{bill && (
|
{bill && (
|
||||||
<section className="partner-bill-card">
|
<section className="partner-bill-card">
|
||||||
@@ -66,73 +149,136 @@ export default function BillsPage() {
|
|||||||
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
|
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
|
||||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
|
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="partner-status-pill partner-status-pill--paused">{String(bill.status || '待确认')}</span>
|
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
|
||||||
|
{billStatusLabel(status)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isRejected && bill.rejectReason && (
|
||||||
|
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
|
||||||
|
<div>
|
||||||
|
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}>账单已驳回</p>
|
||||||
|
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUnpaid && (
|
||||||
|
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span>
|
||||||
|
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
||||||
|
您已确认账单,总部打款中,请耐心等待。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="partner-bill-amount">
|
<div className="partner-bill-amount">
|
||||||
<p className="text-muted body-md" style={{ marginBottom: 8 }}>应结总金额</p>
|
<p className="text-muted body-md" style={{ marginBottom: 8 }}>应结总金额</p>
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
|
||||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
|
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
|
||||||
<span className="amount-xl">{Number(bill.totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
<span className="amount-xl">{fmtMoney(Number(bill.totalAmount || 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<div className="partner-info-row">
|
<div className="partner-info-row">
|
||||||
<span className="text-variant body-md">订单分佣收入</span>
|
<span className="text-variant body-md">订单分佣收入</span>
|
||||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.82).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-info-row">
|
<div className="partner-info-row">
|
||||||
<span className="text-variant body-md">核销权益分佣</span>
|
<span className="text-variant body-md">核销权益分佣</span>
|
||||||
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.18).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bills.length > 1 && (
|
{bills.length > 0 && (
|
||||||
<div style={{ padding: '0 20px' }}>
|
<div style={{ padding: '0 20px' }}>
|
||||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>历史账单</h3>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
{bills.slice(1).map((b) => (
|
<h3 className="headline-md" style={{ margin: 0 }}>全部账单</h3>
|
||||||
<div key={String(b.id)} className="partner-store-card" style={{ margin: '0 0 12px' }}>
|
{actionable.length > 1 && (
|
||||||
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<p className="body-md">{String(b.billNo)}</p>
|
className="partner-fill-max"
|
||||||
<p className="label-md text-muted">{String(b.status)}</p>
|
style={{ fontSize: 13 }}
|
||||||
</div>
|
disabled={!selectedIds.length || submitting}
|
||||||
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
|
onClick={() => askConfirm(selectedIds)}
|
||||||
|
>
|
||||||
|
批量确认 ({selectedIds.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{bills.map((b) => {
|
||||||
|
const id = String(b.id);
|
||||||
|
const selectable = canConfirm(b.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="partner-store-card"
|
||||||
|
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{selectable ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.includes(id)}
|
||||||
|
onChange={() => toggleSelect(id)}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ width: 16 }} />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/center/bills?id=${id}`)}
|
||||||
|
>
|
||||||
|
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{b.billNo}</p>
|
||||||
|
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
|
||||||
|
{b.status === 'REJECTED' && b.rejectReason ? (
|
||||||
|
<p className="label-md text-primary" style={{ marginTop: 4 }}>驳回:{b.rejectReason}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="partner-info-banner" style={{ marginTop: 16 }}>
|
{showApplyForm && (
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
|
<>
|
||||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
<div className="partner-info-banner" style={{ marginTop: 16 }}>
|
||||||
账单确认后将正式进入打款流程。如有异议,请在确认前联系城市运营经理核实数据。
|
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
|
||||||
</p>
|
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
||||||
</div>
|
确认后账单变为「未打款」,由总部完成打款。如有异议请先联系城市运营经理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{bill && (
|
<footer className="partner-bill-footer">
|
||||||
<footer className="partner-bill-footer">
|
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
|
||||||
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
|
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
||||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
<span>我已核对数据无误,同意根据此账单进行结算</span>
|
||||||
<span>我已核对数据无误,同意根据此账单进行结算</span>
|
</label>
|
||||||
</label>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="partner-btn-primary"
|
||||||
className="partner-btn-primary"
|
disabled={!confirmed || submitting}
|
||||||
disabled={!confirmed || submitting}
|
style={{ opacity: confirmed ? 1 : 0.5 }}
|
||||||
style={{ opacity: confirmed ? 1 : 0.5 }}
|
onClick={() => askConfirm([String(bill!.id)])}
|
||||||
onClick={confirmBill}
|
>
|
||||||
>
|
{submitting ? '正在提交...' : '确认账单'}
|
||||||
{submitting ? '正在提交...' : '确认并申请打款'}
|
{!submitting && <span className="material-symbols-outlined">payments</span>}
|
||||||
{!submitting && <span className="material-symbols-outlined">payments</span>}
|
</button>
|
||||||
</button>
|
</footer>
|
||||||
</footer>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,159 +1,301 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||||
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import { request } from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
import { contactSupport } from '../lib/contact';
|
||||||
import { isPrimaryAccount } from '../lib/partnerAccess';
|
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||||
|
import { listPartnerStaff } from '../lib/staff';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
import {
|
||||||
|
authorizePartnerWechat,
|
||||||
|
fetchClientConfig,
|
||||||
|
handlePartnerWechatLoginResult,
|
||||||
|
} from '../lib/wechat-auth';
|
||||||
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
|
|
||||||
export default function CenterPage() {
|
type CenterPageProps = {
|
||||||
const navigate = useNavigate();
|
/** 子账号个人中心复用 */
|
||||||
const { account, logout } = usePartnerSession();
|
variant?: 'primary' | 'sub';
|
||||||
const showPrimaryMenus = isPrimaryAccount(account);
|
roleLabel?: string;
|
||||||
const me = account as unknown as Record<string, unknown> | null;
|
};
|
||||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
|
||||||
|
const { account, logout, refresh, applySession } = usePartnerSession();
|
||||||
|
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
|
||||||
|
const [authorizing, setAuthorizing] = useState(false);
|
||||||
|
const [bills, setBills] = useState<PartnerBillDto[]>([]);
|
||||||
|
const [staffCount, setStaffCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
document.title = isPrimary ? '合伙人中心' : '个人中心';
|
||||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
}, [isPrimary]);
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const pendingBills = bills.filter((b) => String(b.status).includes('PENDING') || String(b.status).includes('CONFIRM'));
|
const loadCenter = useCallback(() => {
|
||||||
|
const tasks: Promise<unknown>[] = [Promise.resolve(refresh())];
|
||||||
|
if (isPrimary) {
|
||||||
|
tasks.push(
|
||||||
|
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
||||||
|
.then(setBills)
|
||||||
|
.catch(() => setBills([])),
|
||||||
|
listPartnerStaff()
|
||||||
|
.then((list) => setStaffCount(list.length))
|
||||||
|
.catch(() => setStaffCount(0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.all(tasks).then(() => undefined);
|
||||||
|
}, [isPrimary, refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCenter();
|
||||||
|
}, [loadCenter]);
|
||||||
|
|
||||||
|
const finance = useMemo(() => {
|
||||||
|
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
|
||||||
|
const settled = bills.filter((b) => b.status === 'PAID');
|
||||||
|
const paidCount = settled.length;
|
||||||
|
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
|
||||||
|
return {
|
||||||
|
pendingBillCount: pending.length,
|
||||||
|
rejectedCount,
|
||||||
|
pendingTotal: bills
|
||||||
|
.filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED')
|
||||||
|
.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||||
|
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||||
|
balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||||
|
paidCount,
|
||||||
|
};
|
||||||
|
}, [bills]);
|
||||||
|
|
||||||
|
function handleContactPartner() {
|
||||||
|
if (isPrimary) {
|
||||||
|
if (!contactSupport()) toastError('暂无客服电话');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!account?.primaryPhone) {
|
||||||
|
toastError('暂无合伙人联系方式');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = `tel:${account.primaryPhone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAvatarAuth() {
|
||||||
|
if (authorizing) return;
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
toastError('请在微信内打开以授权头像昵称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAuthorizing(true);
|
||||||
|
try {
|
||||||
|
const config = await fetchClientConfig();
|
||||||
|
if (!isWxAuthorizeEnabled(config)) {
|
||||||
|
toastError('微信授权暂未开启');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await authorizePartnerWechat();
|
||||||
|
if (result) {
|
||||||
|
const session = handlePartnerWechatLoginResult(result);
|
||||||
|
if (session) applySession(session);
|
||||||
|
await refresh();
|
||||||
|
toastSuccess('微信授权成功');
|
||||||
|
}
|
||||||
|
// result 为空表示已跳转 OAuth,无需额外处理
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '微信授权失败');
|
||||||
|
} finally {
|
||||||
|
setAuthorizing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationLabel = [account?.companyName].filter(Boolean).join(' · ') || '—';
|
||||||
|
const displayName = account?.wxNickname?.trim() || account?.name || '合伙人';
|
||||||
|
const avatarUrl = account?.wxAvatarUrl?.trim() || '';
|
||||||
|
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page partner-center-page">
|
<PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top">
|
||||||
<header className="header app-page-header">
|
|
||||||
<h1 className="app-page-title">杜康好客</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="partner-profile-card">
|
<section className="partner-profile-card">
|
||||||
<div className="partner-profile-avatar">
|
<button
|
||||||
<span className="material-symbols-outlined">person</span>
|
type="button"
|
||||||
</div>
|
className="partner-profile-avatar"
|
||||||
<div style={{ flex: 1 }}>
|
onClick={() => void handleAvatarAuth()}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
disabled={authorizing}
|
||||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(me?.name || '合伙人')}</h2>
|
aria-label={avatarUrl ? '更新微信头像' : '微信授权获取头像'}
|
||||||
<span className="partner-role-badge">城市合伙人</span>
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img src={avatarUrl} alt="" className="partner-profile-avatar-img" referrerPolicy="no-referrer" />
|
||||||
|
) : (
|
||||||
|
<span className="material-symbols-outlined">person</span>
|
||||||
|
)}
|
||||||
|
{!avatarUrl && (
|
||||||
|
<span className="partner-profile-avatar-hint">授权</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="partner-profile-main">
|
||||||
|
<div className="partner-profile-title-row">
|
||||||
|
<h2 className="partner-profile-name">{displayName}</h2>
|
||||||
|
<span className="partner-role-badge">{badgeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}>
|
<div className="partner-profile-location">
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
|
||||||
<span className="body-md">{String(me?.companyName || '郑州')}</span>
|
<span className="body-md line-2-clamp">{locationLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(me?.phone || '')}</p>
|
{!isPrimary && (
|
||||||
</div>
|
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 20px 8px' }}>
|
|
||||||
<h3 className="headline-md">资产概览</h3>
|
|
||||||
<Link to="/center/bills" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
明细 <span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link to="/center/bills" className="partner-bills-banner">
|
|
||||||
<div className="partner-bills-banner-left">
|
|
||||||
<div className="partner-bills-icon">
|
|
||||||
<span className="material-symbols-outlined">pending_actions</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="body-md" style={{ fontWeight: 500 }}>待确认账单</p>
|
|
||||||
<p className="label-md text-primary">您有 {pendingBills.length || bills.length} 笔账单待确认</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="material-symbols-outlined text-primary">chevron_right</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="partner-finance-grid">
|
|
||||||
<div className="partner-finance-card partner-finance-card--hero">
|
|
||||||
<p className="label-md" style={{ opacity: 0.8, marginBottom: 4 }}>账户余额 (元)</p>
|
|
||||||
<span className="amount-xl" style={{ color: '#fff', fontSize: 32 }}>
|
|
||||||
{bills.length > 0 ? Number(bills[0].totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) : '0.00'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="partner-finance-card">
|
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待结算</p>
|
|
||||||
<p className="headline-md">
|
|
||||||
<span className="text-primary">¥</span>
|
|
||||||
{pendingBills.reduce((s, b) => s + Number(b.totalAmount || 0), 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="partner-finance-card">
|
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>账单笔数</p>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span className="headline-md">{bills.length} 笔</span>
|
|
||||||
<span className="material-symbols-outlined text-muted">history</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-menu-section">
|
|
||||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>运营管理</h3>
|
|
||||||
<div className="partner-menu-card">
|
|
||||||
<Link to="/stores" className="partner-menu-item">
|
|
||||||
<div className="partner-menu-item-left">
|
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">store</span></div>
|
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>门店管理</span>
|
|
||||||
</div>
|
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/orders" className="partner-menu-item">
|
|
||||||
<div className="partner-menu-item-left">
|
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">receipt_long</span></div>
|
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>订单中心</span>
|
|
||||||
</div>
|
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/center/settlement" className="partner-menu-item">
|
|
||||||
<div className="partner-menu-item-left">
|
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
|
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>财务对账</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
|
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link to="/reports/weekly" className="partner-menu-item">
|
|
||||||
<div className="partner-menu-item-left">
|
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">monitoring</span></div>
|
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>数据周报</span>
|
|
||||||
</div>
|
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/reshipments" className="partner-menu-item">
|
|
||||||
<div className="partner-menu-item-left">
|
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">assignment_return</span></div>
|
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>补发处理</span>
|
|
||||||
</div>
|
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/leaderboard" className="partner-menu-item">
|
|
||||||
<div className="partner-menu-item-left">
|
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">military_tech</span></div>
|
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>合伙人贡献榜</span>
|
|
||||||
</div>
|
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
|
||||||
</Link>
|
|
||||||
{showPrimaryMenus && (
|
|
||||||
<Link to="/center/staff" className="partner-menu-item">
|
|
||||||
<div className="partner-menu-item-left">
|
|
||||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
|
|
||||||
<span className="body-md" style={{ fontSize: 16 }}>子账号管理</span>
|
|
||||||
</div>
|
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{isPrimary && (
|
||||||
|
<>
|
||||||
|
<section className="partner-assets-section">
|
||||||
|
<div className="partner-section-head">
|
||||||
|
<h3 className="headline-md">资产概览</h3>
|
||||||
|
<Link to="/center/settlement" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
明细 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{finance.pendingBillCount > 0 && (
|
||||||
|
<Link to="/center/bills" className="partner-bills-banner">
|
||||||
|
<div className="partner-bills-banner-left">
|
||||||
|
<div className="partner-bills-icon">
|
||||||
|
<span className="material-symbols-outlined">receipt_long</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="body-md text-primary" style={{ fontWeight: 600 }}>
|
||||||
|
{finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'}
|
||||||
|
</p>
|
||||||
|
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
|
||||||
|
{finance.rejectedCount > 0
|
||||||
|
? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)`
|
||||||
|
: `您有 ${finance.pendingBillCount} 笔账单待确认`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-primary">chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="partner-finance-grid">
|
||||||
|
<div className="partner-finance-card partner-finance-card--hero">
|
||||||
|
<p className="partner-finance-hero-label">账户余额 (元)</p>
|
||||||
|
<p className="partner-finance-hero-amount">{fmtMoney(finance.balance)}</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/center/settlement" className="partner-finance-card">
|
||||||
|
<p className="label-md text-muted">待结算</p>
|
||||||
|
<p className="partner-finance-value text-primary">¥ {fmtMoney(finance.pendingTotal)}</p>
|
||||||
|
</Link>
|
||||||
|
<Link to="/center/settlement" className="partner-finance-card">
|
||||||
|
<p className="label-md text-muted" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
提现记录
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>history</span>
|
||||||
|
</p>
|
||||||
|
<p className="partner-finance-value">{finance.paidCount} 笔</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-menu-section">
|
||||||
|
<div className="partner-section-head">
|
||||||
|
<h3 className="headline-md">运营管理</h3>
|
||||||
|
</div>
|
||||||
|
<div className="partner-menu-card">
|
||||||
|
<Link to="/center/staff" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">badge</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>我的员工</span>
|
||||||
|
</div>
|
||||||
|
<div className="partner-menu-item-right">
|
||||||
|
{staffCount > 0 && <span className="partner-menu-badge">{staffCount}人</span>}
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link to="/stores?audit=pending" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">storefront</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>门店审核记录</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
<Link to="/stores" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">description</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>合同管理</span>
|
||||||
|
</div>
|
||||||
|
<div className="partner-menu-item-right">
|
||||||
|
<span className="partner-menu-status">待续约</span>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-menu-item"
|
||||||
|
onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}
|
||||||
|
>
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">support_agent</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>联系客服</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPrimary && (
|
||||||
|
<section className="partner-menu-section">
|
||||||
|
<div className="partner-menu-card">
|
||||||
|
<Link to="/leaderboard" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">emoji_events</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>团队贡献榜</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>联系客服</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>联系合伙人</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="button" className="partner-logout-btn" onClick={logout}>
|
<button type="button" className="partner-logout-btn" onClick={logout}>
|
||||||
<span className="material-symbols-outlined">logout</span>
|
<span className="material-symbols-outlined">logout</span>
|
||||||
退出登录
|
退出登录
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}>
|
<div className="partner-center-footer">
|
||||||
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,280 +1,369 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||||
import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus';
|
import {
|
||||||
|
canAccessPartnerDashboard,
|
||||||
|
canAccessPartnerOrders,
|
||||||
|
canAccessPartnerStores,
|
||||||
|
getPartnerNavKind,
|
||||||
|
hasWarehouseAccess,
|
||||||
|
} from '../lib/partnerAccess';
|
||||||
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
|
||||||
function fmtMoney(n: number) {
|
function fmtMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOME_STORE_PREVIEW_LIMIT = 6;
|
const LEADERBOARD_PREVIEW_LIMIT = 3;
|
||||||
|
|
||||||
|
function OrderSummarySection({
|
||||||
|
title,
|
||||||
|
todayCount,
|
||||||
|
pendingShip,
|
||||||
|
shipping,
|
||||||
|
completed,
|
||||||
|
ordersLink = '/orders',
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
todayCount: number;
|
||||||
|
pendingShip: number;
|
||||||
|
shipping: number;
|
||||||
|
completed: number;
|
||||||
|
ordersLink?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="partner-order-summary-header">
|
||||||
|
<h2 className="headline-md">{title || `今日订单 ${todayCount}`}</h2>
|
||||||
|
<Link to={ordersLink} className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
查看全部 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="partner-order-stats">
|
||||||
|
<div className="partner-order-stat">
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待发货</p>
|
||||||
|
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
|
||||||
|
</div>
|
||||||
|
<div className="partner-order-stat partner-order-stat--blue">
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>配送中</p>
|
||||||
|
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
|
||||||
|
</div>
|
||||||
|
<div className="partner-order-stat partner-order-stat--green">
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>已完成</p>
|
||||||
|
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPendingShip(status: string) {
|
||||||
|
const s = status.toUpperCase();
|
||||||
|
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShipping(status: string) {
|
||||||
|
const s = status.toUpperCase();
|
||||||
|
return s.includes('SHIP') || s === 'OUT_WAREHOUSE' || s === 'PENDING_RECEIVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompleted(status: string) {
|
||||||
|
return status.toUpperCase() === 'COMPLETED';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(dateStr: string) {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
return d.getFullYear() === now.getFullYear()
|
||||||
|
&& d.getMonth() === now.getMonth()
|
||||||
|
&& d.getDate() === now.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeOrders(orders: Array<Record<string, unknown>>) {
|
||||||
|
let pendingShip = 0;
|
||||||
|
let shipping = 0;
|
||||||
|
let completed = 0;
|
||||||
|
let todayCount = 0;
|
||||||
|
for (const o of orders) {
|
||||||
|
const status = String(o.status || '');
|
||||||
|
if (isToday(String(o.createdAt || ''))) todayCount += 1;
|
||||||
|
if (isPendingShip(status)) pendingShip += 1;
|
||||||
|
else if (isShipping(status)) shipping += 1;
|
||||||
|
else if (isCompleted(status)) completed += 1;
|
||||||
|
}
|
||||||
|
return { pendingShip, shipping, completed, todayCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) {
|
||||||
|
return (
|
||||||
|
<section className="partner-bento-card partner-leaderboard-preview">
|
||||||
|
<div className="partner-leaderboard-header">
|
||||||
|
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
|
||||||
|
团队贡献榜
|
||||||
|
</h3>
|
||||||
|
<Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
查看全部 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="label-md text-muted">暂无排行数据</p>
|
||||||
|
) : (
|
||||||
|
entries.map((entry) => (
|
||||||
|
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
||||||
|
<div className={`partner-leaderboard-avatar partner-leaderboard-avatar--rank-${Math.min(entry.rank, 3)}`}>
|
||||||
|
<span className="material-symbols-outlined">person</span>
|
||||||
|
<span className="partner-leaderboard-avatar-badge">{entry.rank}</span>
|
||||||
|
</div>
|
||||||
|
<div className="partner-leaderboard-info">
|
||||||
|
<p className="body-md" style={{ fontWeight: 600 }}>
|
||||||
|
{entry.name}
|
||||||
|
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
|
||||||
|
</p>
|
||||||
|
<p className="label-md text-muted">累计拓店: {entry.totalStores} 间</p>
|
||||||
|
</div>
|
||||||
|
<div className="partner-leaderboard-stat">
|
||||||
|
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||||
|
<p className="label-md text-muted">本月新增</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { account } = usePartnerSession();
|
||||||
|
const navKind = getPartnerNavKind(account);
|
||||||
|
const isPrimary = navKind === 'primary';
|
||||||
|
const isWarehouse = navKind === 'warehouse_staff';
|
||||||
|
const warehouseOk = hasWarehouseAccess(account);
|
||||||
|
const canOrders = canAccessPartnerOrders(account) && warehouseOk && (isPrimary || isWarehouse);
|
||||||
|
const canDashboard = canAccessPartnerDashboard(account) && !isWarehouse;
|
||||||
|
const canStores = canAccessPartnerStores(account) && !isWarehouse;
|
||||||
|
|
||||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]);
|
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [notifOpen, setNotifOpen] = useState(false);
|
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
document.title = '工作台';
|
||||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
|
}, []);
|
||||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
|
||||||
fetchPartnerLeaderboard('month')
|
const loadHome = useCallback(() => {
|
||||||
.then((data) => setLeaderboardPreview(data.list.slice(0, 2)))
|
if (!isLoggedIn()) {
|
||||||
.catch(() => setLeaderboardPreview([]));
|
navigate('/login');
|
||||||
}, [navigate]);
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (!account) return Promise.resolve();
|
||||||
|
|
||||||
|
const tasks: Promise<unknown>[] = [];
|
||||||
|
|
||||||
|
if (canOrders) {
|
||||||
|
tasks.push(
|
||||||
|
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
|
||||||
|
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||||
|
.catch(() => setOrders([])),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setOrders([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canDashboard) {
|
||||||
|
tasks.push(
|
||||||
|
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||||
|
.then(setDash)
|
||||||
|
.catch(() => setDash(null)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setDash(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canStores) {
|
||||||
|
tasks.push(
|
||||||
|
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||||
|
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setStores([])),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setStores([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
fetchPartnerLeaderboard('month')
|
||||||
|
.then((data) => {
|
||||||
|
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLeaderboardEntries([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.all(tasks).then(() => undefined);
|
||||||
|
}, [navigate, account, canOrders, canDashboard, canStores]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadHome();
|
||||||
|
}, [loadHome]);
|
||||||
|
|
||||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||||
const orderCount = Number(dash?.orderCount || 0);
|
const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
|
||||||
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
|
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
|
||||||
const notifications = Array.isArray(dash?.notifications)
|
|
||||||
? (dash.notifications as Array<Record<string, unknown>>)
|
|
||||||
: [];
|
|
||||||
const hasUnread = notifications.length > 0;
|
|
||||||
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
|
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
|
||||||
const abnormalStores = Math.max(0, storeCount - activeStores);
|
const abnormalStores = Math.max(0, storeCount - activeStores);
|
||||||
const revenue = orderCount * 128.45;
|
|
||||||
const profit = revenue * 0.25;
|
|
||||||
const pendingShip = Math.ceil(orderCount * 0.04);
|
|
||||||
const shipping = Math.ceil(orderCount * 0.12);
|
|
||||||
const completed = Math.max(0, orderCount - pendingShip - shipping);
|
|
||||||
const monthNew = stores.filter((s) => {
|
const monthNew = stores.filter((s) => {
|
||||||
const created = new Date(String(s.createdAt || ''));
|
const created = new Date(String(s.createdAt || ''));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
|
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
|
||||||
}).length;
|
}).length;
|
||||||
|
const lastMonthNew = stores.filter((s) => {
|
||||||
|
const created = new Date(String(s.createdAt || ''));
|
||||||
|
const now = new Date();
|
||||||
|
const last = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
return created.getMonth() === last.getMonth() && created.getFullYear() === last.getFullYear();
|
||||||
|
}).length;
|
||||||
|
const monthGrowthPct = lastMonthNew > 0
|
||||||
|
? Math.round(((monthNew - lastMonthNew) / lastMonthNew) * 100)
|
||||||
|
: (monthNew > 0 ? 100 : 0);
|
||||||
|
|
||||||
const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT);
|
const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
|
||||||
|
const revenue = orderCount * 128.45;
|
||||||
|
const profit = revenue * 0.25;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page partner-home">
|
<PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top">
|
||||||
<header className="partner-home-header">
|
|
||||||
<h1 className="app-page-title">工作台</h1>
|
|
||||||
<div className="partner-home-header-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-notif-btn"
|
|
||||||
aria-label="通知"
|
|
||||||
onClick={() => setNotifOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined">notifications</span>
|
|
||||||
{hasUnread && <span className="partner-notif-dot" />}
|
|
||||||
</button>
|
|
||||||
<div className="partner-profile-avatar" style={{ width: 40, height: 40 }}>
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{notifOpen && (
|
|
||||||
<section className="partner-form-card" style={{ margin: '0 16px 12px' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
||||||
<h2 className="headline-md">审核通知</h2>
|
|
||||||
<button type="button" className="label-md text-muted" onClick={() => setNotifOpen(false)}>关闭</button>
|
|
||||||
</div>
|
|
||||||
{notifications.length === 0 ? (
|
|
||||||
<p className="label-md text-muted">暂无审核通知</p>
|
|
||||||
) : (
|
|
||||||
notifications.slice(0, 8).map((n) => (
|
|
||||||
<Link
|
|
||||||
key={String(n.id)}
|
|
||||||
to={`/stores/${n.storeId}`}
|
|
||||||
className="partner-home-store-row"
|
|
||||||
style={{ marginBottom: 8 }}
|
|
||||||
onClick={() => setNotifOpen(false)}
|
|
||||||
>
|
|
||||||
<div className="partner-home-store-info">
|
|
||||||
<p className="body-md" style={{ fontWeight: 600 }}>{String(n.title || '门店审核')}</p>
|
|
||||||
<p className="label-md text-muted">{String(n.content || '')}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<main className="partner-home-body">
|
<main className="partner-home-body">
|
||||||
<section className="partner-revenue-card">
|
{isPrimary && (
|
||||||
<p className="partner-revenue-label">
|
<>
|
||||||
实时营业额 (CNY)
|
<section className="partner-revenue-card">
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
|
<p className="partner-revenue-label">
|
||||||
</p>
|
实时营业额 (CNY)
|
||||||
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
|
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
|
||||||
<div className="partner-revenue-grid">
|
</p>
|
||||||
<div>
|
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
|
||||||
<p className="partner-revenue-label">预计利润</p>
|
<div className="partner-revenue-grid">
|
||||||
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="partner-bento">
|
|
||||||
<section className="partner-bento-card">
|
|
||||||
<div className="partner-bento-header">
|
|
||||||
<h2 className="headline-md">门店总数</h2>
|
|
||||||
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="partner-store-stats">
|
|
||||||
<div className="partner-store-stat">
|
|
||||||
<span className="partner-dot partner-dot--green" />
|
|
||||||
<div>
|
<div>
|
||||||
<p className="label-md text-muted">正常运营</p>
|
<p className="partner-revenue-label">预计利润</p>
|
||||||
<p className="headline-md">{activeStores}</p>
|
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-store-stat">
|
</section>
|
||||||
<span className="partner-dot partner-dot--red" />
|
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||||
<div>
|
<div className="partner-quick-actions">
|
||||||
<p className="label-md text-muted">异常/闭店</p>
|
<Link to="/proxy-order" className="partner-quick-action">
|
||||||
<p className="headline-md">{abnormalStores}</p>
|
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||||
</div>
|
<span className="material-symbols-outlined">shopping_cart_checkout</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-bento-card">
|
|
||||||
<div className="partner-quick-actions">
|
|
||||||
<Link to="/proxy-order" className="partner-quick-action">
|
|
||||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
|
||||||
<span className="material-symbols-outlined">shopping_cart_checkout</span>
|
|
||||||
</div>
|
|
||||||
<span className="partner-quick-action-label">代下单</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/stores/new" className="partner-quick-action">
|
|
||||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
|
||||||
<span className="material-symbols-outlined">add_business</span>
|
|
||||||
</div>
|
|
||||||
<span className="partner-quick-action-label">录入新店</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/reshipments" className="partner-quick-action">
|
|
||||||
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
|
|
||||||
<span className="material-symbols-outlined">assignment_return</span>
|
|
||||||
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
|
|
||||||
</div>
|
|
||||||
<span className="partner-quick-action-label">补发处理</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/center/settlement" className="partner-quick-action">
|
|
||||||
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
|
||||||
<span className="material-symbols-outlined">account_balance</span>
|
|
||||||
</div>
|
|
||||||
<span className="partner-quick-action-label">财务对账</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/reports/weekly" className="partner-quick-action">
|
|
||||||
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
|
|
||||||
<span className="material-symbols-outlined">monitoring</span>
|
|
||||||
</div>
|
|
||||||
<span className="partner-quick-action-label">数据周报</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div className="partner-order-summary-header">
|
|
||||||
<h2 className="headline-md">今日订单量 {orderCount}</h2>
|
|
||||||
<Link to="/orders" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
查看订单详情 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="partner-order-stats">
|
|
||||||
<div className="partner-order-stat">
|
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待发货</p>
|
|
||||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
|
|
||||||
</div>
|
|
||||||
<div className="partner-order-stat partner-order-stat--blue">
|
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>配送中</p>
|
|
||||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
|
|
||||||
</div>
|
|
||||||
<div className="partner-order-stat partner-order-stat--green">
|
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>已完成</p>
|
|
||||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-home-store-section">
|
|
||||||
<div className="partner-home-store-header">
|
|
||||||
<h2 className="headline-md">辖区门店</h2>
|
|
||||||
<Link to="/stores" className="label-md text-primary">查看全部</Link>
|
|
||||||
</div>
|
|
||||||
{previewStores.length === 0 ? (
|
|
||||||
<div className="partner-home-store-empty">
|
|
||||||
<p className="label-md text-muted">暂无门店</p>
|
|
||||||
<Link to="/stores/new" className="label-md text-primary">录入新店</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
previewStores.map((s) => {
|
|
||||||
const storeId = String(s.id);
|
|
||||||
const status = String(s.status || 'PAUSED').toUpperCase();
|
|
||||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
|
||||||
return (
|
|
||||||
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
|
|
||||||
<div className="partner-home-store-info">
|
|
||||||
<p className="headline-md">{String(s.name || '未命名门店')}</p>
|
|
||||||
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}>
|
<span className="partner-quick-action-label">代下单</span>
|
||||||
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
<Link to="/stores/new" className="partner-quick-action">
|
||||||
})
|
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||||
)}
|
<span className="material-symbols-outlined">add_business</span>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-expansion-card">
|
|
||||||
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
|
||||||
<div className="partner-expansion-split">
|
|
||||||
<div>
|
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
|
||||||
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="partner-leaderboard-section">
|
|
||||||
<div className="partner-leaderboard-header">
|
|
||||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
|
|
||||||
合伙人贡献榜
|
|
||||||
</h3>
|
|
||||||
<Link to="/leaderboard" className="label-md text-primary">查看全部</Link>
|
|
||||||
</div>
|
|
||||||
{leaderboardPreview.length === 0 ? (
|
|
||||||
<p className="label-md text-muted">暂无排行数据</p>
|
|
||||||
) : (
|
|
||||||
leaderboardPreview.map((entry) => (
|
|
||||||
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
|
||||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
|
||||||
<div className="partner-leaderboard-info">
|
|
||||||
<p className="body-md" style={{ fontWeight: 600 }}>
|
|
||||||
{entry.name}
|
|
||||||
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
|
|
||||||
</p>
|
|
||||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-leaderboard-stat">
|
<span className="partner-quick-action-label">录入新店</span>
|
||||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
</Link>
|
||||||
<p className="label-md text-muted">本月新增</p>
|
<Link to="/center/settlement" className="partner-quick-action">
|
||||||
|
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
||||||
|
<span className="material-symbols-outlined">account_balance</span>
|
||||||
|
</div>
|
||||||
|
<span className="partner-quick-action-label">财务对账</span>
|
||||||
|
</Link>
|
||||||
|
<Link to="/reports/weekly" className="partner-quick-action">
|
||||||
|
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
|
||||||
|
<span className="material-symbols-outlined">monitoring</span>
|
||||||
|
</div>
|
||||||
|
<span className="partner-quick-action-label">数据周报</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isWarehouse && (
|
||||||
|
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canOrders && (
|
||||||
|
<OrderSummarySection
|
||||||
|
title={isWarehouse ? '今日订单' : undefined}
|
||||||
|
todayCount={orderStats.todayCount}
|
||||||
|
pendingShip={orderStats.pendingShip}
|
||||||
|
shipping={orderStats.shipping}
|
||||||
|
completed={orderStats.completed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isPrimary || isWarehouse) && !warehouseOk && (
|
||||||
|
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="partner-warehouse-denied">
|
||||||
|
<span className="material-symbols-outlined text-primary">warehouse</span>
|
||||||
|
<p className="body-md" style={{ fontWeight: 600, marginTop: 8 }}>未配置仓库管理权限</p>
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 4, lineHeight: 1.5 }}>
|
||||||
|
购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canStores && (
|
||||||
|
<>
|
||||||
|
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="partner-bento-header">
|
||||||
|
<h2 className="headline-md">门店总数</h2>
|
||||||
|
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="partner-store-stats">
|
||||||
|
<div className="partner-store-stat">
|
||||||
|
<span className="partner-dot partner-dot--green" />
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">正常运营</p>
|
||||||
|
<p className="headline-md">{activeStores}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
<div className="partner-store-stat">
|
||||||
)}
|
<span className="partner-dot partner-dot--red" />
|
||||||
</div>
|
<div>
|
||||||
|
<p className="label-md text-muted">异常/闭店</p>
|
||||||
|
<p className="headline-md">{abnormalStores}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
<section className="partner-expansion-card">
|
||||||
{String(dash?.companyName || '郑州合伙人')} · 辖区管理
|
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
||||||
</p>
|
<div className="partner-expansion-split">
|
||||||
</section>
|
<div>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
||||||
|
<div className="partner-expansion-value-row">
|
||||||
|
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
||||||
|
{monthGrowthPct !== 0 && (
|
||||||
|
<span className={`partner-expansion-trend${monthGrowthPct > 0 ? ' partner-expansion-trend--up' : ' partner-expansion-trend--down'}`}>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>
|
||||||
|
{monthGrowthPct > 0 ? 'trending_up' : 'trending_down'}
|
||||||
|
</span>
|
||||||
|
{Math.abs(monthGrowthPct)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||||
|
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LeaderboardPreview entries={leaderboardEntries} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
|
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { isPrimaryAccount } from '../lib/partnerAccess';
|
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||||
@@ -33,10 +34,10 @@ export default function LeaderboardPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
const loadLeaderboard = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
fetchPartnerLeaderboard(period)
|
return fetchPartnerLeaderboard(period)
|
||||||
.then(setData)
|
.then(setData)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setData(null);
|
setData(null);
|
||||||
@@ -45,9 +46,13 @@ export default function LeaderboardPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [period]);
|
}, [period]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadLeaderboard();
|
||||||
|
}, [loadLeaderboard]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab">
|
<PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab">
|
||||||
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
|
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
|
||||||
|
|
||||||
<div className="partner-leaderboard-tabs">
|
<div className="partner-leaderboard-tabs">
|
||||||
{PERIOD_TABS.map((tab) => (
|
{PERIOD_TABS.map((tab) => (
|
||||||
@@ -124,6 +129,6 @@ export default function LeaderboardPage() {
|
|||||||
<span className="material-symbols-outlined">add</span>
|
<span className="material-symbols-outlined">add</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
type LegalPageProps = {
|
||||||
|
docId: LegalDocument['id'];
|
||||||
|
/** 返回登录页的路径,如 /login */
|
||||||
|
backTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** H5 各端共用的协议/隐私正文页 */
|
||||||
|
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
|
||||||
|
const doc = getLegalDocument(docId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="legal-h5-page">
|
||||||
|
<header className="legal-h5-header">
|
||||||
|
<Link to={backTo} className="legal-h5-back" aria-label="返回">
|
||||||
|
‹
|
||||||
|
</Link>
|
||||||
|
<h1 className="legal-h5-title">{doc.title}</h1>
|
||||||
|
</header>
|
||||||
|
<main className="legal-h5-body">
|
||||||
|
<p className="legal-h5-updated">更新日期:{doc.updatedAt}</p>
|
||||||
|
<p className="legal-h5-intro">{doc.intro}</p>
|
||||||
|
{doc.sections.map((section) => (
|
||||||
|
<section key={section.heading} className="legal-h5-section">
|
||||||
|
<h2>{section.heading}</h2>
|
||||||
|
{section.paragraphs.map((p, i) => (
|
||||||
|
<p key={`${section.heading}-${i}`}>{p}</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,42 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useRef, useState, type RefObject } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
|
||||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
|
||||||
import {
|
import {
|
||||||
getLastPhone,
|
getLastPhone,
|
||||||
getPartnerProfile,
|
getPartnerProfile,
|
||||||
hasPartnerWxSession,
|
|
||||||
request,
|
request,
|
||||||
saveAuth,
|
saveRememberedSession,
|
||||||
type PartnerSessionPayload,
|
type PartnerSessionPayload,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { partnerHomePath } from '../lib/partnerAccess';
|
import { partnerHomePath } from '../lib/partnerAccess';
|
||||||
import {
|
|
||||||
bindPartnerWechatAfterSmsLogin,
|
|
||||||
canPartnerUseWechatLogin,
|
|
||||||
fetchClientConfig,
|
|
||||||
loginPartnerWithWechat,
|
|
||||||
PARTNER_WECHAT_LOGIN_HINT,
|
|
||||||
} from '../lib/wechat-auth';
|
|
||||||
import { isWechatEnv } from '../lib/weixin';
|
|
||||||
|
|
||||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||||
|
|
||||||
function maskPhone(phone: string) {
|
function AgreementCheckbox({
|
||||||
if (phone.length < 7) return phone;
|
agreed,
|
||||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
onChange,
|
||||||
|
inputRef,
|
||||||
|
}: {
|
||||||
|
agreed: boolean;
|
||||||
|
onChange: (next: boolean) => void;
|
||||||
|
inputRef?: RefObject<HTMLLabelElement>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
|
||||||
|
<input type="checkbox" checked={agreed} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
<span>
|
||||||
|
我已阅读并同意{' '}
|
||||||
|
<Link to="/legal/user-agreement" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
《用户协议》
|
||||||
|
</Link>{' '}
|
||||||
|
与{' '}
|
||||||
|
<Link to="/legal/privacy-policy" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
《隐私政策》
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRememberedPhone(): { phone: string; remember: boolean } {
|
function loadRememberedPhone(): { phone: string; remember: boolean } {
|
||||||
@@ -50,44 +60,24 @@ function formatPartnerError(e: unknown): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWechatError(e: unknown): string {
|
|
||||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
|
||||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
|
||||||
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession, refresh, account } = usePartnerSession();
|
const { applySession, refresh, account } = usePartnerSession();
|
||||||
const [params] = useSearchParams();
|
|
||||||
const quick = params.get('quick') === '1';
|
|
||||||
const savedProfile = getPartnerProfile();
|
const savedProfile = getPartnerProfile();
|
||||||
const remembered = loadRememberedPhone();
|
const remembered = loadRememberedPhone();
|
||||||
const [phone, setPhone] = useState(remembered.phone || getLastPhone());
|
const [phone, setPhone] = useState(remembered.phone || getLastPhone());
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
||||||
const [agreed, setAgreed] = useState(true);
|
const [agreed, setAgreed] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [wxLoading, setWxLoading] = useState(false);
|
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchClientConfig()
|
|
||||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
|
||||||
.catch(() => setWxAuthorize(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const quickName = savedProfile?.name ?? '城市合伙人';
|
|
||||||
const quickCompany = savedProfile?.companyName ?? '';
|
|
||||||
const quickPhone = savedProfile?.phone || phone;
|
|
||||||
|
|
||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先勾选并同意用户协议');
|
setMsg('请先勾选并同意用户协议');
|
||||||
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -147,14 +137,9 @@ export default function LoginPage() {
|
|||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone, code }),
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
saveAuth(data);
|
saveRememberedSession(data);
|
||||||
applySession(data);
|
applySession(data);
|
||||||
persistRememberAccount(phone);
|
persistRememberAccount(phone);
|
||||||
if (isWechatEnv() && wxAuthorize) {
|
|
||||||
setMsg('登录成功,正在关联微信…');
|
|
||||||
await bindPartnerWechatAfterSmsLogin();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await finishLoginNavigate();
|
await finishLoginNavigate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(formatPartnerError(e));
|
setMsg(formatPartnerError(e));
|
||||||
@@ -163,110 +148,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wechatLogin() {
|
|
||||||
if (!ensureAgreed()) return;
|
|
||||||
setMsg('');
|
|
||||||
if (!isWechatEnv()) {
|
|
||||||
setMsg(WECHAT_INAPP_REQUIRED_MSG);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const canUseWechat = await canPartnerUseWechatLogin({
|
|
||||||
profile: savedProfile,
|
|
||||||
phone,
|
|
||||||
});
|
|
||||||
if (!canUseWechat) {
|
|
||||||
setMsg(PARTNER_WECHAT_LOGIN_HINT);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setWxLoading(true);
|
|
||||||
try {
|
|
||||||
const session = await loginPartnerWithWechat();
|
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
await finishLoginNavigate();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(formatWechatError(e));
|
|
||||||
} finally {
|
|
||||||
setWxLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quick) {
|
|
||||||
const canWechatQuick =
|
|
||||||
wxAuthorize &&
|
|
||||||
isWechatEnv() &&
|
|
||||||
hasPartnerWxSession() &&
|
|
||||||
!!savedProfile &&
|
|
||||||
savedProfile.hasWechat === true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="partner-auth-page partner-auth-page--quick">
|
|
||||||
<header className="partner-auth-brand">
|
|
||||||
<div className="partner-quick-avatar" style={{ width: 120, height: 120, margin: '0 auto 16px' }}>
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>wine_bar</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="partner-auth-title" style={{ fontSize: 20 }}>杜康好客</h1>
|
|
||||||
<p className="partner-auth-subtitle" style={{ fontSize: 12, letterSpacing: '0.2em', textTransform: 'uppercase' }}>城市合伙人端</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="partner-glass-card">
|
|
||||||
<div className="partner-quick-badge">已识别账号</div>
|
|
||||||
<div className="partner-quick-avatar">
|
|
||||||
<span className="material-symbols-outlined">person</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<h2 className="headline-md">
|
|
||||||
{quickName}
|
|
||||||
{quickCompany ? (
|
|
||||||
<span className="text-muted body-md" style={{ fontWeight: 400 }}> ({quickCompany})</span>
|
|
||||||
) : null}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted body-md" style={{ letterSpacing: '0.1em', marginTop: 4 }}>
|
|
||||||
{quickPhone ? maskPhone(quickPhone) : '暂无已保存账号'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
|
||||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
|
||||||
{canWechatQuick ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-btn-primary"
|
|
||||||
disabled={wxLoading}
|
|
||||||
onClick={() => void wechatLogin()}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined">chat</span>
|
|
||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="partner-auth-msg" style={{ textAlign: 'center', marginBottom: 12 }}>
|
|
||||||
{wxAuthorize && !isWechatEnv()
|
|
||||||
? '请在微信内打开以使用一键登录'
|
|
||||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!canWechatQuick && (
|
|
||||||
<Link to="/login" className="partner-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none', marginTop: 12 }}>
|
|
||||||
验证码登录
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}>切换账号</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<footer className="partner-auth-footer">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<span className="material-symbols-outlined">verified_user</span>
|
|
||||||
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
|
||||||
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-auth-page">
|
<div className="partner-auth-page">
|
||||||
<div className="partner-auth-brand">
|
<div className="partner-auth-brand">
|
||||||
@@ -321,38 +202,24 @@ export default function LoginPage() {
|
|||||||
<span>记住账号</span>
|
<span>记住账号</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<AgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
inputRef={agreementRef}
|
||||||
|
/>
|
||||||
|
|
||||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
|
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
|
||||||
|
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
|
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
|
||||||
<span>{loading ? '登录中...' : '登录'}</span>
|
<span>{loading ? '登录中...' : '登录'}</span>
|
||||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="partner-auth-msg" style={{ textAlign: 'center' }}>
|
||||||
{wxAuthorize && (
|
手机号验证成功后,7 天内无需再次输入验证码
|
||||||
<>
|
</p>
|
||||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
|
||||||
<button type="button" className="partner-btn-wechat" onClick={() => void wechatLogin()} disabled={wxLoading}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
|
|
||||||
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
|
|
||||||
</svg>
|
|
||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="partner-checkbox-row">
|
|
||||||
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
|
|
||||||
<span>
|
|
||||||
我已阅读并同意 <span className="text-primary" style={{ fontWeight: 600 }}>《用户协议》</span> 与 <span className="text-primary" style={{ fontWeight: 600 }}>《隐私政策》</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{hasPartnerWxSession() && savedProfile?.hasWechat && (
|
|
||||||
<Link to="/login?quick=1" className="partner-link">微信快捷登录</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<footer className="partner-auth-footer">
|
<footer className="partner-auth-footer">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span className="material-symbols-outlined">verified_user</span>
|
<span className="material-symbols-outlined">verified_user</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
|
||||||
const TIMELINE = [
|
const TIMELINE = [
|
||||||
{ key: 'confirm', label: '待确认' },
|
{ key: 'confirm', label: '待确认' },
|
||||||
@@ -28,21 +29,55 @@ function statusBanner(status: string) {
|
|||||||
return { title: status, desc: '', icon: 'receipt_long' };
|
return { title: status, desc: '', icon: 'receipt_long' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canShip(status: string) {
|
||||||
|
const s = status.toUpperCase();
|
||||||
|
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
|
||||||
|
}
|
||||||
|
|
||||||
export default function OrderDetailPage() {
|
export default function OrderDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [order, setOrder] = useState<Record<string, unknown> | null>(null);
|
const [order, setOrder] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [shipping, setShipping] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
|
document.title = '订单详情';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) void request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
if (!id) return;
|
||||||
|
const next = await request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`);
|
||||||
|
setOrder(next);
|
||||||
|
}
|
||||||
|
|
||||||
async function advance(status: string) {
|
async function advance(status: string) {
|
||||||
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
|
if (!id) return;
|
||||||
method: 'POST',
|
setShipping(true);
|
||||||
body: JSON.stringify({ targetStatus: status }),
|
try {
|
||||||
});
|
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
|
||||||
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ targetStatus: status }),
|
||||||
|
});
|
||||||
|
await reload();
|
||||||
|
toastSuccess('已更新配送状态');
|
||||||
|
} finally {
|
||||||
|
setShipping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContactCourier() {
|
||||||
|
const delivery = order?.delivery as Record<string, unknown> | undefined;
|
||||||
|
const trackingNo = String(delivery?.trackingNo || delivery?.providerOrderNo || '').trim();
|
||||||
|
if (trackingNo) {
|
||||||
|
void navigator.clipboard?.writeText(trackingNo);
|
||||||
|
toastSuccess(`运单号已复制:${trackingNo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toastError('暂无配送员联系方式');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!order) return <div className="empty">加载中...</div>;
|
if (!order) return <div className="empty">加载中...</div>;
|
||||||
@@ -50,6 +85,7 @@ export default function OrderDetailPage() {
|
|||||||
const banner = statusBanner(String(order.status));
|
const banner = statusBanner(String(order.status));
|
||||||
const currentIdx = statusIndex(String(order.status));
|
const currentIdx = statusIndex(String(order.status));
|
||||||
const payAmount = Number(order.payAmount || 0);
|
const payAmount = Number(order.payAmount || 0);
|
||||||
|
const showShip = canShip(String(order.status));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-order-detail">
|
<div className="partner-order-detail">
|
||||||
@@ -91,9 +127,6 @@ export default function OrderDetailPage() {
|
|||||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}>
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}>
|
||||||
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
|
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(166,29,36,0.8)', textAlign: 'center', padding: '2px 0' }}>
|
|
||||||
<span className="label-md" style={{ color: '#fff', fontSize: 10 }}>正品保证</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
|
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
|
||||||
@@ -106,39 +139,6 @@ export default function OrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, margin: '0 20px' }}>
|
|
||||||
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-heritage-red)' }}>
|
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 8 }}>佣金明细</p>
|
|
||||||
<div className="partner-info-row">
|
|
||||||
<span className="label-md text-muted">订单佣金</span>
|
|
||||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="partner-info-row">
|
|
||||||
<span className="label-md text-muted">权益核销</span>
|
|
||||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-aged-amber)' }}>
|
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>赠送好客权益</p>
|
|
||||||
<span className="amount-lg" style={{ color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="partner-detail-section">
|
|
||||||
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
|
|
||||||
订单信息
|
|
||||||
</h4>
|
|
||||||
<div className="partner-info-row">
|
|
||||||
<span className="text-muted body-md">订单编号</span>
|
|
||||||
<span className="body-md">{String(order.orderNo)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="partner-info-row">
|
|
||||||
<span className="text-muted body-md">订单状态</span>
|
|
||||||
<span className="body-md">{String(order.status)}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-detail-section">
|
<section className="partner-detail-section">
|
||||||
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
|
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
|
||||||
@@ -156,21 +156,34 @@ export default function OrderDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="partner-order-footer">
|
<footer className="partner-order-footer">
|
||||||
<div>
|
<div className="partner-order-footer-actions">
|
||||||
<span className="label-md text-muted">佣金合计</span>
|
{showShip && (
|
||||||
<p className="headline-md text-primary" style={{ fontWeight: 700 }}>¥{(payAmount * 0.08).toFixed(2)}</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary partner-order-ship-btn--footer"
|
||||||
|
disabled={shipping}
|
||||||
|
onClick={() => void advance('OUT_WAREHOUSE')}
|
||||||
|
>
|
||||||
|
{shipping ? '发货中…' : '确认发货'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}
|
||||||
|
onClick={handleContactCourier}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
|
||||||
|
联系配送员
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-outline" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}>
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
|
|
||||||
联系配送员
|
|
||||||
</button>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{import.meta.env.DEV && (
|
{import.meta.env.DEV && (
|
||||||
<details className="partner-dev-tools">
|
<details className="partner-dev-tools">
|
||||||
<summary>Dev: Mock 推进配送</summary>
|
<summary>Dev: Mock 推进配送</summary>
|
||||||
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
|
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
|
||||||
<button key={s} type="button" onClick={() => advance(s)}>{s}</button>
|
<button key={s} type="button" onClick={() => void advance(s)}>{s}</button>
|
||||||
))}
|
))}
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
import { hasWarehouseAccess } from '../lib/partnerAccess';
|
||||||
|
|
||||||
type DateFilter = 'today' | '7d' | '30d';
|
type DateFilter = 'today' | '7d' | '30d';
|
||||||
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
|
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
|
||||||
@@ -29,17 +32,59 @@ const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
|||||||
{ key: 'ABNORMAL', label: '异常' },
|
{ key: 'ABNORMAL', label: '异常' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function OrderListPage() {
|
type OrderListPageProps = {
|
||||||
|
/** Tab 根页:无返回顶栏 */
|
||||||
|
tabRoot?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrdersResponse = {
|
||||||
|
list: Array<Record<string, unknown>>;
|
||||||
|
hasWarehouseAccess?: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] });
|
const { account } = usePartnerSession();
|
||||||
|
const warehouseOk = hasWarehouseAccess(account);
|
||||||
|
const [data, setData] = useState<OrdersResponse>({ list: [] });
|
||||||
const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
|
const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
|
||||||
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
|
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||||
|
const [shippingId, setShippingId] = useState<string | null>(null);
|
||||||
|
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
|
||||||
|
const [shipModalId, setShipModalId] = useState<string | null>(null);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
document.title = '订单管理';
|
||||||
request('PARTNER_H5', '/partner/orders').then(setData);
|
}, []);
|
||||||
}, [navigate]);
|
|
||||||
|
const loadOrders = useCallback(() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
navigate('/login');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (!warehouseOk) {
|
||||||
|
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
|
||||||
|
setLoaded(true);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return request<OrdersResponse>('PARTNER_H5', '/partner/orders')
|
||||||
|
.then((res) => {
|
||||||
|
setData({
|
||||||
|
list: Array.isArray(res.list) ? res.list : [],
|
||||||
|
hasWarehouseAccess: res.hasWarehouseAccess,
|
||||||
|
message: res.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => setData({ list: [] }))
|
||||||
|
.finally(() => setLoaded(true));
|
||||||
|
}, [navigate, warehouseOk]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOrders();
|
||||||
|
}, [loadOrders]);
|
||||||
|
|
||||||
const filtered = useMemo(() => data.list.filter((o) => {
|
const filtered = useMemo(() => data.list.filter((o) => {
|
||||||
if (statusFilter === 'ALL') return true;
|
if (statusFilter === 'ALL') return true;
|
||||||
@@ -51,13 +96,68 @@ export default function OrderListPage() {
|
|||||||
return true;
|
return true;
|
||||||
}), [data.list, statusFilter]);
|
}), [data.list, statusFilter]);
|
||||||
|
|
||||||
|
async function handleShip(orderId: string, e: React.MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShipModalId(orderId);
|
||||||
|
setShipForm({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitManualShip() {
|
||||||
|
if (!shipModalId) return;
|
||||||
|
if (!shipForm.logisticsCompany.trim() || !shipForm.trackingNo.trim()) {
|
||||||
|
window.alert('请填写快递公司和运单号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShippingId(shipModalId);
|
||||||
|
try {
|
||||||
|
await request('PARTNER_H5', `/partner/orders/${shipModalId}/manual-ship`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(shipForm),
|
||||||
|
});
|
||||||
|
setShipModalId(null);
|
||||||
|
const next = await request<OrdersResponse>('PARTNER_H5', '/partner/orders');
|
||||||
|
setData({
|
||||||
|
list: Array.isArray(next.list) ? next.list : [],
|
||||||
|
hasWarehouseAccess: next.hasWarehouseAccess,
|
||||||
|
message: next.message,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof Error ? err.message : '发货失败');
|
||||||
|
} finally {
|
||||||
|
setShippingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canShip(status: string) {
|
||||||
|
const s = status.toUpperCase();
|
||||||
|
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loaded && !warehouseOk) {
|
||||||
|
return (
|
||||||
|
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||||
|
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||||
|
<div className="partner-warehouse-denied" style={{ margin: 20, padding: 32, textAlign: 'center' }}>
|
||||||
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: 40 }}>warehouse</span>
|
||||||
|
<p className="headline-md" style={{ marginTop: 12 }}>未配置仓库管理权限</p>
|
||||||
|
<p className="body-md text-muted" style={{ marginTop: 8, lineHeight: 1.6 }}>
|
||||||
|
购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab partner-orders-page">
|
<PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||||
<PageHeader title="订单中心" onBack={() => navigate('/')} />
|
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||||
|
|
||||||
<div className="partner-segment">
|
<div className="partner-segment">
|
||||||
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}>订单列表</button>
|
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}>订单列表</button>
|
||||||
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}>权益记录</button>
|
{!tabRoot && (
|
||||||
|
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}>权益记录</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'orders' && (
|
{tab === 'orders' && (
|
||||||
@@ -82,50 +182,45 @@ export default function OrderListPage() {
|
|||||||
{filtered.map((o) => {
|
{filtered.map((o) => {
|
||||||
const st = orderStatusLabel(String(o.status));
|
const st = orderStatusLabel(String(o.status));
|
||||||
const payAmount = Number(o.payAmount || 0);
|
const payAmount = Number(o.payAmount || 0);
|
||||||
|
const orderId = String(o.id);
|
||||||
return (
|
return (
|
||||||
<Link key={String(o.id)} to={`/orders/${o.id}`} className="partner-order-card">
|
<div key={orderId} className="partner-order-card-wrap">
|
||||||
<div className="partner-order-card-top">
|
<Link to={`/orders/${orderId}`} className="partner-order-card">
|
||||||
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
|
<div className="partner-order-card-top">
|
||||||
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
|
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
|
||||||
</div>
|
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
|
||||||
<div className="partner-order-product">
|
|
||||||
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<span className="material-symbols-outlined text-muted">liquor</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div className="partner-order-product">
|
||||||
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
|
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
|
<span className="material-symbols-outlined text-muted">liquor</span>
|
||||||
<span className="partner-benefit-tag">
|
</div>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>confirmation_number</span>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
¥{Math.round(payAmount * 0.2)}权益
|
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
|
||||||
</span>
|
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="partner-order-address">
|
||||||
<div className="partner-order-address">
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
|
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
|
||||||
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="partner-order-commission">
|
|
||||||
<div>
|
|
||||||
<p className="label-md text-muted">下单佣金</p>
|
|
||||||
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥{(payAmount * 0.04).toFixed(2)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</Link>
|
||||||
<p className="label-md text-muted">核销佣金</p>
|
{canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
|
||||||
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥0.00</p>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div>
|
className="partner-order-ship-btn"
|
||||||
<p className="label-md text-muted">获赠权益</p>
|
disabled={shippingId === orderId}
|
||||||
<p className="headline-md" style={{ marginTop: 4, color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</p>
|
onClick={(e) => void handleShip(orderId, e)}
|
||||||
</div>
|
>
|
||||||
</div>
|
填写运单
|
||||||
</Link>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'coupons' && (
|
{tab === 'coupons' && !tabRoot && (
|
||||||
<div style={{ padding: '0 20px' }}>
|
<div style={{ padding: '0 20px' }}>
|
||||||
<div className="partner-revenue-card" style={{ marginBottom: 16 }}>
|
<div className="partner-revenue-card" style={{ marginBottom: 16 }}>
|
||||||
<p className="partner-revenue-label">累计已发放权益金额</p>
|
<p className="partner-revenue-label">累计已发放权益金额</p>
|
||||||
@@ -134,6 +229,50 @@ export default function OrderListPage() {
|
|||||||
<p className="text-muted body-md text-center">权益记录 preV1 占位</p>
|
<p className="text-muted body-md text-center">权益记录 preV1 占位</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{shipModalId && (
|
||||||
|
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setShipModalId(null)}>
|
||||||
|
<div className="partner-ship-modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="headline-md">填写运单</h3>
|
||||||
|
<label className="partner-ship-field">
|
||||||
|
<span>快递公司</span>
|
||||||
|
<input
|
||||||
|
value={shipForm.logisticsCompany}
|
||||||
|
onChange={(e) => setShipForm((f) => ({ ...f, logisticsCompany: e.target.value }))}
|
||||||
|
placeholder="如 顺丰速运"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="partner-ship-field">
|
||||||
|
<span>运单号</span>
|
||||||
|
<input
|
||||||
|
value={shipForm.trackingNo}
|
||||||
|
onChange={(e) => setShipForm((f) => ({ ...f, trackingNo: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="partner-ship-field">
|
||||||
|
<span>查询链接(可选)</span>
|
||||||
|
<input
|
||||||
|
value={shipForm.manualQueryUrl}
|
||||||
|
onChange={(e) => setShipForm((f) => ({ ...f, manualQueryUrl: e.target.value }))}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="partner-ship-actions">
|
||||||
|
<button type="button" className="partner-btn-secondary" onClick={() => setShipModalId(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
disabled={shippingId === shipModalId}
|
||||||
|
onClick={() => void submitManualShip()}
|
||||||
|
>
|
||||||
|
{shippingId === shipModalId ? '提交中…' : '确认发货'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { getPartnerNavKind } from '../lib/partnerAccess';
|
||||||
import { PARTNER_STAFF_ROLE_LABELS, type PartnerStaffRole } from '@dukang/shared-types';
|
|
||||||
import { request } from '../lib/api';
|
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import CenterPage from './CenterPage';
|
||||||
|
|
||||||
|
/** 子账号个人中心:复用精简版 Center 布局 */
|
||||||
export default function PartnerMePage() {
|
export default function PartnerMePage() {
|
||||||
const { account, refresh, logout } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
const [name, setName] = useState(account?.name ?? '');
|
const navKind = getPartnerNavKind(account);
|
||||||
const [saving, setSaving] = useState(false);
|
const isWarehouse = navKind === 'warehouse_staff';
|
||||||
|
|
||||||
useEffect(() => {
|
return <CenterPage variant="sub" roleLabel={isWarehouse ? '仓库管理员' : '拓店员'} />;
|
||||||
setName(account?.name ?? '');
|
|
||||||
}, [account?.name]);
|
|
||||||
|
|
||||||
const roleLabel = account?.staffRole
|
|
||||||
? PARTNER_STAFF_ROLE_LABELS[account.staffRole as PartnerStaffRole] || account.staffRole
|
|
||||||
: '拓店账号';
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
const trimmed = name.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
toastError('请输入姓名');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (trimmed === account?.name) {
|
|
||||||
toastSuccess('已保存');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await request('PARTNER_H5', '/partner/me', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ name: trimmed }),
|
|
||||||
});
|
|
||||||
await refresh();
|
|
||||||
toastSuccess('已保存');
|
|
||||||
} catch (e) {
|
|
||||||
toastError(e instanceof Error ? e.message : '保存失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page partner-me-page">
|
|
||||||
<section className="partner-profile-card" style={{ margin: '16px var(--space-page)' }}>
|
|
||||||
<div className="partner-profile-avatar">
|
|
||||||
<span className="material-symbols-outlined">person</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '我的'}</h2>
|
|
||||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{roleLabel}</p>
|
|
||||||
{account?.companyName ? (
|
|
||||||
<p className="body-md text-muted" style={{ marginTop: 4 }}>{account.companyName}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-form-section" style={{ padding: '0 var(--space-page)' }}>
|
|
||||||
<label className="partner-form-label" htmlFor="me-name">姓名</label>
|
|
||||||
<input
|
|
||||||
id="me-name"
|
|
||||||
className="partner-form-input"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="请输入姓名"
|
|
||||||
maxLength={32}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label className="partner-form-label" style={{ marginTop: 16 }}>手机号</label>
|
|
||||||
<input
|
|
||||||
className="partner-form-input"
|
|
||||||
value={account?.phone ?? ''}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>手机号由管理员维护,如需修改请联系总部</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div style={{ padding: '24px var(--space-page) 100px' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-btn-primary"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => void handleSave()}
|
|
||||||
>
|
|
||||||
{saving ? '保存中…' : '保存'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-btn-outline"
|
|
||||||
style={{ width: '100%', marginTop: 12 }}
|
|
||||||
onClick={logout}
|
|
||||||
>
|
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
|
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import type {
|
import type {
|
||||||
|
PartnerProxyDeliveryMode,
|
||||||
PartnerProxyOrderCreateRequest,
|
PartnerProxyOrderCreateRequest,
|
||||||
PartnerProxyOrderOptions,
|
PartnerProxyOrderOptions,
|
||||||
PartnerProxyOrderPreviewResult,
|
PartnerProxyOrderPreviewResult,
|
||||||
@@ -17,19 +18,25 @@ function fmtMoney(n: number) {
|
|||||||
|
|
||||||
export default function ProxyOrderPage() {
|
export default function ProxyOrderPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
||||||
const [loadingOptions, setLoadingOptions] = useState(true);
|
const [loadingOptions, setLoadingOptions] = useState(true);
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [smsCode, setSmsCode] = useState('');
|
const [customerSmsCode, setCustomerSmsCode] = useState('');
|
||||||
|
const [partnerSmsCode, setPartnerSmsCode] = useState('');
|
||||||
const [receiverName, setReceiverName] = useState('');
|
const [receiverName, setReceiverName] = useState('');
|
||||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||||
const [addressDetail, setAddressDetail] = useState('');
|
const [addressDetail, setAddressDetail] = useState('');
|
||||||
const [productId, setProductId] = useState('');
|
const [productId, setProductId] = useState('');
|
||||||
const [quantity, setQuantity] = useState(2);
|
const [quantity, setQuantity] = useState(2);
|
||||||
const [promoCodeId, setPromoCodeId] = useState('');
|
const [promoCodeId, setPromoCodeId] = useState('');
|
||||||
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||||
|
const [autoReceive, setAutoReceive] = useState(false);
|
||||||
|
const [confirmStep, setConfirmStep] = useState(false);
|
||||||
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
const [customerCooldown, setCustomerCooldown] = useState(0);
|
||||||
|
const [partnerCooldown, setPartnerCooldown] = useState(0);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
@@ -39,12 +46,26 @@ export default function ProxyOrderPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setOptions(data);
|
setOptions({
|
||||||
if (data.products[0]) setProductId(data.products[0].id);
|
...data,
|
||||||
|
stores: Array.isArray(data.stores) ? data.stores : [],
|
||||||
|
});
|
||||||
|
const fromQuery = searchParams.get('productId');
|
||||||
|
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
|
||||||
|
setProductId(fromQuery);
|
||||||
|
} else if (data.products[0] && !productId) {
|
||||||
|
setProductId(data.products[0].id);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
|
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
|
||||||
.finally(() => setLoadingOptions(false));
|
.finally(() => setLoadingOptions(false));
|
||||||
}, []);
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- hydrate once; productId from query
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fromQuery = searchParams.get('productId');
|
||||||
|
if (fromQuery) setProductId(fromQuery);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!productId || quantity < 1) {
|
if (!productId || quantity < 1) {
|
||||||
@@ -58,8 +79,9 @@ export default function ProxyOrderPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
receiverCity: region.city || undefined,
|
deliveryMode,
|
||||||
receiverDistrict: region.district || undefined,
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||||
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||||
}),
|
}),
|
||||||
silent: true,
|
silent: true,
|
||||||
})
|
})
|
||||||
@@ -68,67 +90,109 @@ export default function ProxyOrderPage() {
|
|||||||
.finally(() => setPreviewLoading(false));
|
.finally(() => setPreviewLoading(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [productId, quantity, region.city, region.district]);
|
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
|
||||||
|
|
||||||
async function sendSms() {
|
function startCooldown(setter: (n: number | ((s: number) => number)) => void) {
|
||||||
|
setter(60);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setter((s) => {
|
||||||
|
if (s <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return s - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCustomerSms() {
|
||||||
setMsg('');
|
setMsg('');
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||||
setMsg('请输入有效手机号');
|
setMsg('请输入有效手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', {
|
const res = await request<{ maskedPhone: string }>(
|
||||||
method: 'POST',
|
'PARTNER_H5',
|
||||||
body: JSON.stringify({ phone: phone.trim() }),
|
'/partner/proxy-orders/send-customer-sms',
|
||||||
silent: true,
|
{
|
||||||
});
|
method: 'POST',
|
||||||
setMsg(`验证码已发送至 ${res.maskedPhone}`);
|
body: JSON.stringify({ phone: phone.trim() }),
|
||||||
setSmsCooldown(60);
|
silent: true,
|
||||||
const timer = setInterval(() => {
|
},
|
||||||
setSmsCooldown((s) => {
|
);
|
||||||
if (s <= 1) {
|
setMsg(`客户验证码已发送至 ${res.maskedPhone}`);
|
||||||
clearInterval(timer);
|
startCooldown(setCustomerCooldown);
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return s - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendPartnerSms() {
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
const res = await request<{ maskedPhone: string }>(
|
||||||
|
'PARTNER_H5',
|
||||||
|
'/partner/proxy-orders/send-partner-sms',
|
||||||
|
{ method: 'POST', body: '{}', silent: true },
|
||||||
|
);
|
||||||
|
setMsg(`确认验证码已发送至合伙人手机 ${res.maskedPhone}`);
|
||||||
|
startCooldown(setPartnerCooldown);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): string | null {
|
||||||
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||||
|
if (!customerSmsCode.trim()) return '请输入客户验证码';
|
||||||
|
if (!productId) return '请选择商品';
|
||||||
|
if (deliveryMode !== 'ADDRESS') {
|
||||||
|
// 现场提货不需要地址与门店
|
||||||
|
} else {
|
||||||
|
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||||
|
if (!addressDetail.trim()) return '请填写详细地址';
|
||||||
|
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||||
|
}
|
||||||
|
if (!preview) return '请等待费用计算完成';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfirmStep() {
|
||||||
|
setMsg('');
|
||||||
|
const err = validateForm();
|
||||||
|
if (err) {
|
||||||
|
setMsg(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmStep(true);
|
||||||
|
setPartnerSmsCode('');
|
||||||
|
await sendPartnerSms();
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setMsg('');
|
setMsg('');
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
const err = validateForm();
|
||||||
setMsg('请输入有效手机号');
|
if (err) {
|
||||||
|
setMsg(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!smsCode.trim()) {
|
if (!partnerSmsCode.trim()) {
|
||||||
setMsg('请输入验证码');
|
setMsg('请输入合伙人确认验证码');
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!region?.province || !region.city || !region.district) {
|
|
||||||
setMsg('请选择省市区');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!addressDetail.trim()) {
|
|
||||||
setMsg('请填写详细地址');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!productId) {
|
|
||||||
setMsg('请选择商品');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: PartnerProxyOrderCreateRequest = {
|
const payload: PartnerProxyOrderCreateRequest = {
|
||||||
phone: phone.trim(),
|
phone: phone.trim(),
|
||||||
smsCode: smsCode.trim(),
|
customerSmsCode: customerSmsCode.trim(),
|
||||||
|
partnerSmsCode: partnerSmsCode.trim(),
|
||||||
|
deliveryMode,
|
||||||
|
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
||||||
receiverName: receiverName.trim() || undefined,
|
receiverName: receiverName.trim() || undefined,
|
||||||
province: region.province,
|
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
|
||||||
city: region.city,
|
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
|
||||||
district: region.district,
|
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
|
||||||
addressDetail: addressDetail.trim(),
|
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
|
||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
@@ -151,6 +215,12 @@ export default function ProxyOrderPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
|
const deliveryLabel =
|
||||||
|
preview?.deliveryType === 'ON_SITE_PICKUP'
|
||||||
|
? '现场提货'
|
||||||
|
: preview?.deliveryType === 'CROSS_CITY'
|
||||||
|
? '跨城配送'
|
||||||
|
: '同城配送';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page partner-proxy-order-page">
|
<div className="page partner-proxy-order-page">
|
||||||
@@ -158,7 +228,7 @@ export default function ProxyOrderPage() {
|
|||||||
|
|
||||||
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
|
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
|
||||||
{loadingOptions ? (
|
{loadingOptions ? (
|
||||||
<p className="label-md text-muted">加载商品与推广码…</p>
|
<p className="label-md text-muted">加载商品…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
@@ -175,70 +245,49 @@ export default function ProxyOrderPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={smsCooldown > 0}
|
disabled={customerCooldown > 0}
|
||||||
onClick={() => void sendSms()}
|
onClick={() => void sendCustomerSms()}
|
||||||
>
|
>
|
||||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
{customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
<label className="partner-form-label">短信验证码</label>
|
<label className="partner-form-label">客户验证码</label>
|
||||||
<div className="partner-input-wrap">
|
<div className="partner-input-wrap">
|
||||||
<input
|
<input
|
||||||
className="partner-input"
|
className="partner-input"
|
||||||
placeholder="线下代发货确认码"
|
placeholder="发至客户手机"
|
||||||
value={smsCode}
|
value={customerSmsCode}
|
||||||
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
<label className="partner-form-label">收货人(选填)</label>
|
<label className="partner-form-label">酒品</label>
|
||||||
<div className="partner-input-wrap">
|
<button
|
||||||
<input
|
type="button"
|
||||||
className="partner-input"
|
className="partner-proxy-product-picker"
|
||||||
placeholder="默认:用户+手机尾号"
|
onClick={() =>
|
||||||
value={receiverName}
|
navigate(
|
||||||
onChange={(e) => setReceiverName(e.target.value)}
|
`/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
|
||||||
/>
|
)
|
||||||
</div>
|
}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-form-section">
|
|
||||||
<label className="partner-form-label">收货地区</label>
|
|
||||||
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
|
|
||||||
{regionLabel && <p className="label-md text-muted" style={{ marginTop: 8 }}>{regionLabel}</p>}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-form-section">
|
|
||||||
<label className="partner-form-label">详细地址</label>
|
|
||||||
<div className="partner-field-input partner-field-input--block">
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
placeholder="街道、门牌号等"
|
|
||||||
value={addressDetail}
|
|
||||||
onChange={(e) => setAddressDetail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="partner-form-section">
|
|
||||||
<label className="partner-form-label">商品</label>
|
|
||||||
<select
|
|
||||||
className="partner-input"
|
|
||||||
value={productId}
|
|
||||||
onChange={(e) => setProductId(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
|
|
||||||
>
|
>
|
||||||
{(options?.products ?? []).map((p) => (
|
{selectedProduct ? (
|
||||||
<option key={p.id} value={p.id}>
|
<>
|
||||||
{p.name}({p.spec})¥{fmtMoney(p.price)}
|
<span className="partner-proxy-product-name">{selectedProduct.name}</span>
|
||||||
</option>
|
<span className="label-md text-muted">
|
||||||
))}
|
{selectedProduct.spec} · ¥{fmtMoney(selectedProduct.price)}
|
||||||
</select>
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="label-md text-muted">点击选择酒品</span>
|
||||||
|
)}
|
||||||
|
<span className="partner-proxy-product-picker-arrow">›</span>
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
@@ -261,7 +310,12 @@ export default function ProxyOrderPage() {
|
|||||||
className="partner-input"
|
className="partner-input"
|
||||||
value={promoCodeId}
|
value={promoCodeId}
|
||||||
onChange={(e) => setPromoCodeId(e.target.value)}
|
onChange={(e) => setPromoCodeId(e.target.value)}
|
||||||
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="">不绑定</option>
|
<option value="">不绑定</option>
|
||||||
{options!.promoCodes.map((p) => (
|
{options!.promoCodes.map((p) => (
|
||||||
@@ -273,6 +327,73 @@ export default function ProxyOrderPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section className="partner-form-section">
|
||||||
|
<label className="partner-form-label">履约方式</label>
|
||||||
|
<div className="partner-proxy-mode-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDeliveryMode('ADDRESS')}
|
||||||
|
>
|
||||||
|
配送到址
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
|
||||||
|
>
|
||||||
|
现场提货
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{deliveryMode === 'ADDRESS' ? (
|
||||||
|
<>
|
||||||
|
<section className="partner-form-section">
|
||||||
|
<label className="partner-form-label">收货人(选填)</label>
|
||||||
|
<div className="partner-input-wrap">
|
||||||
|
<input
|
||||||
|
className="partner-input"
|
||||||
|
placeholder="默认:用户+手机尾号"
|
||||||
|
value={receiverName}
|
||||||
|
onChange={(e) => setReceiverName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-form-section">
|
||||||
|
<label className="partner-form-label">收货地区</label>
|
||||||
|
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
|
||||||
|
{regionLabel ? (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
{regionLabel}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="partner-form-section">
|
||||||
|
<label className="partner-form-label">详细地址</label>
|
||||||
|
<div className="partner-field-input partner-field-input--block">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="街道、门牌号等"
|
||||||
|
value={addressDetail}
|
||||||
|
onChange={(e) => setAddressDetail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<label className="partner-proxy-auto-receive">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoReceive}
|
||||||
|
onChange={(e) => setAutoReceive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>同意自动收货(线下代下单提交后视为已送达并发放权益)</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="partner-proxy-fee-card">
|
<section className="partner-proxy-fee-card">
|
||||||
<h3 className="headline-md">费用明细</h3>
|
<h3 className="headline-md">费用明细</h3>
|
||||||
{previewLoading ? (
|
{previewLoading ? (
|
||||||
@@ -288,8 +409,8 @@ export default function ProxyOrderPage() {
|
|||||||
<span className="body-md">×{quantity}</span>
|
<span className="body-md">×{quantity}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-proxy-fee-row">
|
<div className="partner-proxy-fee-row">
|
||||||
<span className="label-md text-muted">配送类型</span>
|
<span className="label-md text-muted">履约类型</span>
|
||||||
<span className="body-md">{preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}</span>
|
<span className="body-md">{deliveryLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-proxy-fee-row">
|
<div className="partner-proxy-fee-row">
|
||||||
<span className="label-md text-muted">权益额</span>
|
<span className="label-md text-muted">权益额</span>
|
||||||
@@ -302,25 +423,78 @@ export default function ProxyOrderPage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="label-md text-muted">
|
<p className="label-md text-muted">
|
||||||
{selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'}
|
{selectedProduct ? '请确认数量与履约信息后查看费用' : '请选择商品'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{msg && <p className="partner-form-error" role="alert">{msg}</p>}
|
{confirmStep ? (
|
||||||
|
<section className="partner-form-section partner-proxy-confirm">
|
||||||
|
<label className="partner-form-label">合伙人确认验证码</label>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 8 }}>
|
||||||
|
确认线下已收款后,将向您的登录手机号发送验证码
|
||||||
|
</p>
|
||||||
|
<div className="partner-input-row">
|
||||||
|
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||||||
|
<input
|
||||||
|
className="partner-input"
|
||||||
|
placeholder="发至合伙人手机"
|
||||||
|
value={partnerSmsCode}
|
||||||
|
onChange={(e) => setPartnerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={partnerCooldown > 0}
|
||||||
|
onClick={() => void sendPartnerSms()}
|
||||||
|
>
|
||||||
|
{partnerCooldown > 0 ? `${partnerCooldown}s` : '重新发送'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<button
|
{msg ? (
|
||||||
type="button"
|
<p className="partner-form-error" role="alert">
|
||||||
className="btn btn-primary btn-block"
|
{msg}
|
||||||
disabled={submitting || !preview}
|
</p>
|
||||||
onClick={() => void submit()}
|
) : null}
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
>
|
{!confirmStep ? (
|
||||||
{submitting ? '提交中…' : '验证码确认并代下单'}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="btn btn-primary btn-block"
|
||||||
|
disabled={!preview}
|
||||||
|
onClick={() => void openConfirmStep()}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
确认代下单
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => setConfirmStep(false)}
|
||||||
|
>
|
||||||
|
返回修改
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
disabled={submitting || !preview}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
{submitting ? '提交中…' : '验证并提交'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
|
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
|
||||||
提交后将自动创建/关联用户,订单类型为「线下代下单」,状态直接标记为已收货,并发放对应权益。
|
线下已收款确认后,将自动创建/关联用户,订单标记为代下单并直接完成,同时发放对应权益。客户订单列表会显示您的姓名。
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError } from '../lib/toast';
|
||||||
|
import type { PartnerProxyOrderOptions, PartnerProxyOrderProductOption } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProxyOrderProductsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const selectedId = searchParams.get('selected') || '';
|
||||||
|
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
||||||
|
.then((data) => setProducts(Array.isArray(data.products) ? data.products : []))
|
||||||
|
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function pick(product: PartnerProxyOrderProductOption) {
|
||||||
|
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page partner-proxy-order-page">
|
||||||
|
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
|
||||||
|
<main className="partner-proxy-product-list">
|
||||||
|
{loading ? <p className="label-md text-muted">加载中…</p> : null}
|
||||||
|
{!loading && products.length === 0 ? (
|
||||||
|
<p className="label-md text-muted">暂无可售商品</p>
|
||||||
|
) : null}
|
||||||
|
{products.map((p) => {
|
||||||
|
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
|
||||||
|
const active = p.id === selectedId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`partner-proxy-product-card${active ? ' partner-proxy-product-card--active' : ''}`}
|
||||||
|
onClick={() => pick(p)}
|
||||||
|
>
|
||||||
|
<div className="partner-proxy-product-thumb">
|
||||||
|
{p.coverUrl ? (
|
||||||
|
<img src={p.coverUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span className="partner-proxy-product-thumb-empty" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="partner-proxy-product-main">
|
||||||
|
<div className="partner-proxy-product-row">
|
||||||
|
<span className="partner-proxy-product-name">{p.name}</span>
|
||||||
|
<span className="partner-proxy-product-price">¥{fmtMoney(p.price)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="partner-proxy-product-spec">{p.spec}</p>
|
||||||
|
<p className="partner-proxy-product-benefit">好客权益 ¥{fmtMoney(benefit)}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
|
||||||
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing';
|
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
|
||||||
|
|
||||||
function fmtMoney(n: number) {
|
function fmtMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
@@ -12,18 +13,20 @@ function fmtMoney(n: number) {
|
|||||||
|
|
||||||
function billStatusLabel(status: string) {
|
function billStatusLabel(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'DRAFT': return '待结算';
|
case 'AWAITING_CONFIRM': return '待确认';
|
||||||
case 'CONFIRMED': return '审核中';
|
case 'UNPAID': return '未打款';
|
||||||
case 'PAID': return '已结算';
|
case 'PAID': return '已打款';
|
||||||
|
case 'REJECTED': return '已驳回';
|
||||||
default: return status;
|
default: return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function billStatusClass(status: string) {
|
function billStatusClass(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'DRAFT': return 'partner-settlement-status--pending';
|
case 'AWAITING_CONFIRM': return 'partner-settlement-status--pending';
|
||||||
case 'CONFIRMED': return 'partner-settlement-status--reviewing';
|
case 'UNPAID': return 'partner-settlement-status--reviewing';
|
||||||
case 'PAID': return 'partner-settlement-status--settled';
|
case 'PAID': return 'partner-settlement-status--settled';
|
||||||
|
case 'REJECTED': return 'partner-settlement-status--rejected';
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,13 +48,17 @@ export default function SettlementPage() {
|
|||||||
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
|
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
|
||||||
|
const loadBills = useCallback(() => {
|
||||||
|
return request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||||
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
void loadBills();
|
||||||
}, [navigate]);
|
}, [navigate, loadBills]);
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
const pending = bills.filter((b) => b.status === 'DRAFT');
|
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
|
||||||
const settled = bills.filter((b) => b.status === 'PAID');
|
const settled = bills.filter((b) => b.status === 'PAID');
|
||||||
const currentMonth = bills.filter((b) => {
|
const currentMonth = bills.filter((b) => {
|
||||||
const d = new Date(b.periodStart);
|
const d = new Date(b.periodStart);
|
||||||
@@ -70,9 +77,10 @@ export default function SettlementPage() {
|
|||||||
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
|
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
|
||||||
if (!matchMonth) return false;
|
if (!matchMonth) return false;
|
||||||
if (statusFilter === 'all') return true;
|
if (statusFilter === 'all') return true;
|
||||||
if (statusFilter === 'pending') return b.status === 'DRAFT';
|
if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM';
|
||||||
if (statusFilter === 'settled') return b.status === 'PAID';
|
if (statusFilter === 'settled') return b.status === 'PAID';
|
||||||
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
|
if (statusFilter === 'reviewing') return b.status === 'UNPAID';
|
||||||
|
if (statusFilter === 'rejected') return b.status === 'REJECTED';
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [bills, month, statusFilter]);
|
}, [bills, month, statusFilter]);
|
||||||
@@ -92,13 +100,14 @@ export default function SettlementPage() {
|
|||||||
|
|
||||||
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
|
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
|
||||||
{ key: 'all', label: '全部' },
|
{ key: 'all', label: '全部' },
|
||||||
{ key: 'pending', label: '待结算' },
|
{ key: 'pending', label: '待确认' },
|
||||||
{ key: 'settled', label: '已结算' },
|
{ key: 'reviewing', label: '未打款' },
|
||||||
{ key: 'reviewing', label: '审核中' },
|
{ key: 'rejected', label: '已驳回' },
|
||||||
|
{ key: 'settled', label: '已打款' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab partner-settlement-page">
|
<PullToRefresh onRefresh={loadBills} className="page-no-tab partner-settlement-page">
|
||||||
<PageHeader title="财务对账中心" onBack={() => navigate('/')} />
|
<PageHeader title="财务对账中心" onBack={() => navigate('/')} />
|
||||||
|
|
||||||
<main className="partner-settlement-body">
|
<main className="partner-settlement-body">
|
||||||
@@ -188,7 +197,12 @@ export default function SettlementPage() {
|
|||||||
<span className="partner-settlement-dot" />
|
<span className="partner-settlement-dot" />
|
||||||
订单分佣 ¥{fmtMoney(Number(bill.orderCommission || 0))}
|
订单分佣 ¥{fmtMoney(Number(bill.orderCommission || 0))}
|
||||||
</p>
|
</p>
|
||||||
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' ? ' text-primary' : ''}`}>
|
{bill.status === 'REJECTED' && bill.rejectReason ? (
|
||||||
|
<p className="label-md text-primary" style={{ marginTop: 6 }}>
|
||||||
|
驳回:{bill.rejectReason}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
|
||||||
¥{fmtMoney(Number(bill.totalAmount || 0))}
|
¥{fmtMoney(Number(bill.totalAmount || 0))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,6 +216,6 @@ export default function SettlementPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import {
|
import {
|
||||||
AccountStatus,
|
AccountStatus,
|
||||||
PARTNER_STAFF_ROLE_LABELS,
|
PARTNER_STAFF_ROLE_LABELS,
|
||||||
@@ -86,7 +87,7 @@ export default function StaffListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab partner-staff-page">
|
<PullToRefresh onRefresh={loadStaff} className="page-no-tab partner-staff-page">
|
||||||
<PageHeader title="子账号管理" onBack={() => navigate('/center')} />
|
<PageHeader title="子账号管理" onBack={() => navigate('/center')} />
|
||||||
|
|
||||||
<div style={{ padding: '0 20px 16px' }}>
|
<div style={{ padding: '0 20px 16px' }}>
|
||||||
@@ -151,6 +152,6 @@ export default function StaffListPage() {
|
|||||||
添加子账号
|
添加子账号
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
|
||||||
|
|
||||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||||
|
|
||||||
import OssUploadField from '../components/OssUploadField';
|
import OssUploadField from '../components/OssUploadField';
|
||||||
@@ -13,14 +11,15 @@ import { toastError, toastSuccess } from '../lib/toast';
|
|||||||
|
|
||||||
import { resolveRegionBinding } from '../lib/china-region';
|
import { resolveRegionBinding } from '../lib/china-region';
|
||||||
|
|
||||||
import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
|
import { checkStorePhoneAvailable } from '../lib/storePhone';
|
||||||
|
|
||||||
|
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
|
||||||
|
|
||||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||||
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
|
||||||
|
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { isSubAccount } from '../lib/partnerAccess';
|
|
||||||
|
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
@@ -48,14 +47,14 @@ import {
|
|||||||
|
|
||||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||||
|
|
||||||
|
type StoreCategoryNode = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
children?: StoreCategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
type FieldErrors = {
|
type FieldErrors = {
|
||||||
|
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
||||||
storeSmsCode?: string;
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ function isPhoneConflictMessage(message: string) {
|
|||||||
|
|
||||||
function isPhoneValidationMessage(message: string) {
|
function isPhoneValidationMessage(message: string) {
|
||||||
|
|
||||||
return message.includes('联系电话') || message.includes('手机号') || message.includes('验证码');
|
return message.includes('联系电话') || message.includes('手机号');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,18 +79,10 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { account, refresh } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
|
|
||||||
const subAccountLayout = isSubAccount(account);
|
|
||||||
|
|
||||||
const accountId = account?.id;
|
const accountId = account?.id;
|
||||||
|
|
||||||
const wechatReady = !!account?.hasWechat;
|
|
||||||
|
|
||||||
const handleWechatReadyChange = useCallback(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
|
|
||||||
const saved = loadStoreDraft(accountId);
|
const saved = loadStoreDraft(accountId);
|
||||||
@@ -104,15 +95,15 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const [checkingPhone, setCheckingPhone] = useState(false);
|
|
||||||
|
|
||||||
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
||||||
|
|
||||||
const [citiesError, setCitiesError] = useState('');
|
const [citiesError, setCitiesError] = useState('');
|
||||||
|
|
||||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||||
|
|
||||||
const [smsHint, setSmsHint] = useState('');
|
const [locating, setLocating] = useState(false);
|
||||||
|
|
||||||
|
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
||||||
|
|
||||||
const draftSaveDisabledRef = useRef(false);
|
const draftSaveDisabledRef = useRef(false);
|
||||||
|
|
||||||
@@ -145,17 +136,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (step !== 2 || !isWechatEnv()) return;
|
|
||||||
void refresh();
|
|
||||||
weixinSdk.reset();
|
|
||||||
void weixinSdk.init().catch(() => {
|
|
||||||
/* OssUploadField 点击时会再次初始化 */
|
|
||||||
});
|
|
||||||
}, [step, refresh]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
void fetchPartnerCities()
|
void fetchPartnerCities()
|
||||||
@@ -175,6 +155,26 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<StoreCategoryNode[]>('PARTNER_H5', '/partner/store-categories')
|
||||||
|
.then((list) => {
|
||||||
|
const tree = Array.isArray(list) ? list : [];
|
||||||
|
setCategoryTree(tree);
|
||||||
|
if (form.categoryId && !form.categoryParentId) {
|
||||||
|
const parent = tree.find((root) =>
|
||||||
|
(root.children ?? []).some((child) => child.id === form.categoryId),
|
||||||
|
);
|
||||||
|
if (parent) patchForm({ categoryParentId: parent.id });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setCategoryTree([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categoryChildren = useMemo(() => {
|
||||||
|
const parent = categoryTree.find((item) => item.id === form.categoryParentId);
|
||||||
|
return Array.isArray(parent?.children) ? parent!.children! : [];
|
||||||
|
}, [categoryTree, form.categoryParentId]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -225,25 +225,13 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
|
|
||||||
let nextPatch = patch;
|
|
||||||
|
|
||||||
if ('phone' in patch) {
|
if ('phone' in patch) {
|
||||||
|
|
||||||
setFieldErrors((prev) => ({ ...prev, phone: undefined, storeSmsCode: undefined }));
|
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
||||||
|
|
||||||
setSmsHint('');
|
|
||||||
|
|
||||||
nextPatch = { ...patch, storeSmsCode: '' };
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('storeSmsCode' in patch) {
|
setForm((prev) => ({ ...prev, ...patch }));
|
||||||
|
|
||||||
setFieldErrors((prev) => ({ ...prev, storeSmsCode: undefined }));
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
setForm((prev) => ({ ...prev, ...nextPatch }));
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,72 +291,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function sendStorePhoneCode() {
|
|
||||||
|
|
||||||
const phone = form.phone.trim();
|
|
||||||
|
|
||||||
if (!/^1\d{10}$/.test(phone)) {
|
|
||||||
|
|
||||||
setFieldErrors({ phone: '请先填写正确的11位手机号' });
|
|
||||||
|
|
||||||
return;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
setSmsHint('');
|
|
||||||
|
|
||||||
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
const phoneCheck = await checkStorePhoneAvailable(phone);
|
|
||||||
|
|
||||||
if (!phoneCheck.available) {
|
|
||||||
|
|
||||||
setFieldErrors({ phone: phoneCheck.message ?? '该手机号不可用于门店账号' });
|
|
||||||
|
|
||||||
return;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await sendStorePhoneSms(phone);
|
|
||||||
|
|
||||||
setSmsHint(`验证码已发送至 ${res.maskedPhone}`);
|
|
||||||
|
|
||||||
setSmsCooldown(60);
|
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
|
|
||||||
setSmsCooldown((s) => {
|
|
||||||
|
|
||||||
if (s <= 1) {
|
|
||||||
|
|
||||||
clearInterval(timer);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return s - 1;
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
|
|
||||||
const msg = e instanceof Error ? e.message : '验证码发送失败';
|
|
||||||
|
|
||||||
setFieldErrors({ phone: msg });
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function handleNext() {
|
async function handleNext() {
|
||||||
|
|
||||||
if (step === 1) {
|
if (step === 1) {
|
||||||
@@ -376,54 +298,10 @@ export default function StoreCreatePage() {
|
|||||||
const msg = validateStoreStep1(form);
|
const msg = validateStoreStep1(form);
|
||||||
|
|
||||||
if (msg) {
|
if (msg) {
|
||||||
if (isPhoneValidationMessage(msg)) {
|
reportFormError(msg);
|
||||||
if (msg.includes('验证码')) {
|
|
||||||
setFieldErrors({ storeSmsCode: msg });
|
|
||||||
} else {
|
|
||||||
setFieldErrors({ phone: msg });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reportFormError(msg);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCheckingPhone(true);
|
|
||||||
|
|
||||||
setSubmitError('');
|
|
||||||
|
|
||||||
setFieldErrors({});
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
|
|
||||||
|
|
||||||
if (!phoneCheck.available) {
|
|
||||||
const phoneMsg = phoneCheck.message ?? '该手机号已绑定门店,请更换';
|
|
||||||
setFieldErrors({ phone: phoneMsg });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (phoneCheck.needConfirm) {
|
|
||||||
const ok = window.confirm(
|
|
||||||
phoneCheck.message ??
|
|
||||||
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
|
|
||||||
);
|
|
||||||
if (!ok) {
|
|
||||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
|
||||||
return;
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
|
|
||||||
setCheckingPhone(false);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === 2) {
|
if (step === 2) {
|
||||||
@@ -448,6 +326,10 @@ export default function StoreCreatePage() {
|
|||||||
const msg = validateStoreStep3(form);
|
const msg = validateStoreStep3(form);
|
||||||
|
|
||||||
if (msg) {
|
if (msg) {
|
||||||
|
if (isPhoneValidationMessage(msg)) {
|
||||||
|
setFieldErrors({ phone: msg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
reportFormError(msg);
|
reportFormError(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -455,15 +337,6 @@ export default function StoreCreatePage() {
|
|||||||
const step1Msg = validateStoreStep1(form);
|
const step1Msg = validateStoreStep1(form);
|
||||||
|
|
||||||
if (step1Msg) {
|
if (step1Msg) {
|
||||||
if (isPhoneValidationMessage(step1Msg)) {
|
|
||||||
if (step1Msg.includes('验证码')) {
|
|
||||||
setFieldErrors({ storeSmsCode: step1Msg });
|
|
||||||
} else {
|
|
||||||
setFieldErrors({ phone: step1Msg });
|
|
||||||
}
|
|
||||||
goStep(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reportFormError(step1Msg);
|
reportFormError(step1Msg);
|
||||||
goStep(1);
|
goStep(1);
|
||||||
return;
|
return;
|
||||||
@@ -503,6 +376,8 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -514,6 +389,7 @@ export default function StoreCreatePage() {
|
|||||||
);
|
);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||||
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
confirmBindExisting = true;
|
confirmBindExisting = true;
|
||||||
@@ -521,12 +397,15 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||||
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
|
const envPhotoUrls = Array.from(
|
||||||
|
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
||||||
|
).slice(0, 3);
|
||||||
|
|
||||||
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||||
|
|
||||||
@@ -544,13 +423,31 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
|
|
||||||
smsCode: form.storeSmsCode.trim(),
|
|
||||||
|
|
||||||
district: form.district.trim(),
|
district: form.district.trim(),
|
||||||
|
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
|
|
||||||
|
...(form.latitude.trim() && form.longitude.trim()
|
||||||
|
? {
|
||||||
|
latitude: Number(form.latitude),
|
||||||
|
longitude: Number(form.longitude),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
|
||||||
|
openTime: form.openTime.trim(),
|
||||||
|
|
||||||
|
closeTime: form.closeTime.trim(),
|
||||||
|
|
||||||
|
...(form.dualHours
|
||||||
|
? { openTime2: form.openTime2.trim(), closeTime2: form.closeTime2.trim() }
|
||||||
|
: {}),
|
||||||
|
|
||||||
|
...(form.avgPrice.trim() ? { avgPrice: Number(form.avgPrice) } : {}),
|
||||||
|
|
||||||
|
categoryId: form.categoryId.trim(),
|
||||||
|
|
||||||
intro: form.intro.trim() || undefined,
|
intro: form.intro.trim() || undefined,
|
||||||
|
benefitUsageRule: form.benefitUsageRule.trim() || undefined,
|
||||||
|
|
||||||
coverUrl: form.coverUrl.trim() || undefined,
|
coverUrl: form.coverUrl.trim() || undefined,
|
||||||
|
|
||||||
@@ -575,7 +472,6 @@ export default function StoreCreatePage() {
|
|||||||
setForm(defaultStoreForm());
|
setForm(defaultStoreForm());
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
setSmsHint('');
|
|
||||||
setParams({ step: '1' }, { replace: true });
|
setParams({ step: '1' }, { replace: true });
|
||||||
toastSuccess('门店录入成功');
|
toastSuccess('门店录入成功');
|
||||||
navigate(`/stores/${result.store.id}`);
|
navigate(`/stores/${result.store.id}`);
|
||||||
@@ -585,11 +481,6 @@ export default function StoreCreatePage() {
|
|||||||
setFieldErrors({ phone: message });
|
setFieldErrors({ phone: message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (/验证码/.test(message)) {
|
|
||||||
setFieldErrors({ storeSmsCode: message });
|
|
||||||
goStep(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitError(message);
|
setSubmitError(message);
|
||||||
toastError(message);
|
toastError(message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -604,17 +495,13 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
||||||
|
|
||||||
const nextDisabled = submitting || checkingPhone;
|
const nextDisabled = submitting;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className={`partner-page-sticky${subAccountLayout ? ' partner-page-sticky--tabbar' : ''}`}>
|
<div className="partner-page-sticky partner-home--flush-top">
|
||||||
|
|
||||||
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<nav className="partner-stepper">
|
<nav className="partner-stepper">
|
||||||
|
|
||||||
@@ -714,100 +601,58 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
|
|
||||||
<label>联系电话(门店登录账号) <span className="text-primary">*</span></label>
|
<label>店铺类型 <span className="text-primary">*</span></label>
|
||||||
|
|
||||||
<div className="partner-field-input">
|
<div className="partner-input-row" style={{ gap: 8 }}>
|
||||||
|
|
||||||
<span className="material-symbols-outlined">call</span>
|
<select
|
||||||
|
|
||||||
<input
|
className="partner-field-input partner-field-input--block"
|
||||||
|
|
||||||
type="tel"
|
value={form.categoryParentId}
|
||||||
|
|
||||||
placeholder="请输入11位手机号"
|
onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })}
|
||||||
|
|
||||||
value={form.phone}
|
aria-label="一级店铺类型"
|
||||||
|
|
||||||
onChange={(e) => patchForm({ phone: e.target.value })}
|
|
||||||
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{fieldErrors.phone && (
|
|
||||||
|
|
||||||
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
|
||||||
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
|
||||||
|
|
||||||
验证码将发送至该手机号,需门店负责人确认后方可录入
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="partner-field">
|
|
||||||
|
|
||||||
<label>门店账号验证码 <span className="text-primary">*</span></label>
|
|
||||||
|
|
||||||
<div className="partner-input-row">
|
|
||||||
|
|
||||||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
|
||||||
|
|
||||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
|
||||||
|
|
||||||
<input
|
|
||||||
|
|
||||||
className="partner-input"
|
|
||||||
|
|
||||||
type="text"
|
|
||||||
|
|
||||||
inputMode="numeric"
|
|
||||||
|
|
||||||
maxLength={6}
|
|
||||||
|
|
||||||
placeholder="请输入短信验证码"
|
|
||||||
|
|
||||||
value={form.storeSmsCode}
|
|
||||||
|
|
||||||
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
|
|
||||||
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
|
|
||||||
type="button"
|
|
||||||
|
|
||||||
className="partner-code-btn"
|
|
||||||
|
|
||||||
disabled={smsCooldown > 0 || checkingPhone}
|
|
||||||
|
|
||||||
onClick={() => void sendStorePhoneCode()}
|
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
<option value="">选择大类</option>
|
||||||
|
|
||||||
</button>
|
{categoryTree.map((item) => (
|
||||||
|
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
|
||||||
|
className="partner-field-input partner-field-input--block"
|
||||||
|
|
||||||
|
value={form.categoryId}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ categoryId: e.target.value })}
|
||||||
|
|
||||||
|
disabled={!form.categoryParentId}
|
||||||
|
|
||||||
|
aria-label="二级店铺类型"
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<option value="">{form.categoryParentId ? '选择细类' : '请先选大类'}</option>
|
||||||
|
|
||||||
|
{categoryChildren.map((item) => (
|
||||||
|
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{smsHint && (
|
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
|
||||||
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fieldErrors.storeSmsCode && (
|
|
||||||
|
|
||||||
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
|
|
||||||
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
@@ -816,13 +661,178 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
|
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ padding: '8px 12px', fontSize: 13 }}
|
||||||
|
disabled={locating}
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
setLocating(true);
|
||||||
|
try {
|
||||||
|
const pos = await locateStorePosition();
|
||||||
|
patchForm({
|
||||||
|
latitude: String(pos.latitude),
|
||||||
|
longitude: String(pos.longitude),
|
||||||
|
});
|
||||||
|
toastSuccess('已获取当前坐标');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '定位失败');
|
||||||
|
} finally {
|
||||||
|
setLocating(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locating ? '定位中…' : '获取当前位置'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ padding: '8px 12px', fontSize: 13 }}
|
||||||
|
onClick={() => setMapPickerOpen(true)}
|
||||||
|
>
|
||||||
|
地图选点
|
||||||
|
</button>
|
||||||
|
<span className="label-md text-muted">
|
||||||
|
{formatStoreCoords(form.latitude, form.longitude)
|
||||||
|
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
|
||||||
|
: '未定位(可定位或地图选点)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
|
||||||
|
<label>营业时间 <span className="text-primary">*</span></label>
|
||||||
|
|
||||||
|
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
|
||||||
|
<input
|
||||||
|
|
||||||
|
className="partner-field-input partner-field-input--block"
|
||||||
|
|
||||||
|
type="time"
|
||||||
|
|
||||||
|
value={form.openTime}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ openTime: e.target.value })}
|
||||||
|
|
||||||
|
aria-label="营业开始时间"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="label-md text-muted">至</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
|
||||||
|
className="partner-field-input partner-field-input--block"
|
||||||
|
|
||||||
|
type="time"
|
||||||
|
|
||||||
|
value={form.closeTime}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ closeTime: e.target.value })}
|
||||||
|
|
||||||
|
aria-label="营业结束时间"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="partner-checkbox-row" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
|
||||||
|
<input
|
||||||
|
|
||||||
|
type="checkbox"
|
||||||
|
|
||||||
|
checked={form.dualHours}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ dualHours: e.target.checked })}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="label-md">启用第二段营业时间(如午市/晚市)</span>
|
||||||
|
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{form.dualHours ? (
|
||||||
|
|
||||||
|
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}>
|
||||||
|
|
||||||
|
<input
|
||||||
|
|
||||||
|
className="partner-field-input partner-field-input--block"
|
||||||
|
|
||||||
|
type="time"
|
||||||
|
|
||||||
|
value={form.openTime2}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ openTime2: e.target.value })}
|
||||||
|
|
||||||
|
aria-label="第二段开始"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="label-md text-muted">至</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
|
||||||
|
className="partner-field-input partner-field-input--block"
|
||||||
|
|
||||||
|
type="time"
|
||||||
|
|
||||||
|
value={form.closeTime2}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ closeTime2: e.target.value })}
|
||||||
|
|
||||||
|
aria-label="第二段结束"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
|
||||||
|
支持 1 段(如 09:00-22:00)或 2 段(如 09:00-14:00,17:00-21:00)。
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
|
||||||
|
<label>人均费用(选填)</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
|
||||||
|
className="partner-field-input partner-field-input--block"
|
||||||
|
|
||||||
|
type="number"
|
||||||
|
|
||||||
|
min={0}
|
||||||
|
|
||||||
|
step={1}
|
||||||
|
|
||||||
|
placeholder="如 88,用户端门店页展示"
|
||||||
|
|
||||||
|
value={form.avgPrice}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ avgPrice: e.target.value })}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
|
|
||||||
<label>门店简介</label>
|
<label>门店简介</label>
|
||||||
|
|
||||||
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
|
<textarea rows={4} placeholder="请输入门店简介 (2-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
|
||||||
|
|
||||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||||
|
|
||||||
@@ -832,6 +842,26 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
|
||||||
|
<label>好客权益券使用规则</label>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
placeholder="选填,展示在用户端门店详情,最多1000字"
|
||||||
|
maxLength={1000}
|
||||||
|
value={form.benefitUsageRule}
|
||||||
|
onChange={(e) => patchForm({ benefitUsageRule: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||||
|
|
||||||
|
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="partner-info-banner">
|
<div className="partner-info-banner">
|
||||||
@@ -896,13 +926,9 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
value={form.coverUrl}
|
value={form.coverUrl}
|
||||||
|
|
||||||
wechatReady={wechatReady}
|
|
||||||
|
|
||||||
onWechatReadyChange={handleWechatReadyChange}
|
|
||||||
|
|
||||||
onChange={(coverUrl) => patchForm({ coverUrl })}
|
onChange={(coverUrl) => patchForm({ coverUrl })}
|
||||||
|
|
||||||
label="点击或拖拽上传"
|
label="从系统相册选择"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -932,10 +958,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
value={url}
|
value={url}
|
||||||
|
|
||||||
wechatReady={wechatReady}
|
|
||||||
|
|
||||||
onWechatReadyChange={handleWechatReadyChange}
|
|
||||||
|
|
||||||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
@@ -952,7 +974,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>上传签约协议首页与盖章页</p>
|
||||||
|
|
||||||
<OssUploadField
|
<OssUploadField
|
||||||
|
|
||||||
@@ -964,10 +986,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
value={form.contractUrl}
|
value={form.contractUrl}
|
||||||
|
|
||||||
wechatReady={wechatReady}
|
|
||||||
|
|
||||||
onWechatReadyChange={handleWechatReadyChange}
|
|
||||||
|
|
||||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||||
|
|
||||||
label="上传合同副本"
|
label="上传合同副本"
|
||||||
@@ -1008,6 +1026,54 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="partner-form-card">
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
|
||||||
|
<label>户主姓名 *</label>
|
||||||
|
|
||||||
|
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
|
||||||
|
<label>银行卡号 *</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="partner-field-input partner-field-input--block"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="cc-number"
|
||||||
|
maxLength={19}
|
||||||
|
placeholder="请输入16-19位银行卡号"
|
||||||
|
value={form.bankAccountNo}
|
||||||
|
onChange={(e) => patchForm({ bankAccountNo: e.target.value.replace(/\D/g, '').slice(0, 19) })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
|
||||||
|
<label>开户支行 *</label>
|
||||||
|
|
||||||
|
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
|
||||||
|
|
||||||
|
<span className="material-symbols-outlined text-secondary">info</span>
|
||||||
|
|
||||||
|
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
|
||||||
|
|
||||||
|
请确保银行卡信息准确,以免影响每月的餐费结算。
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="partner-form-card">
|
<section className="partner-form-card">
|
||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
@@ -1040,63 +1106,26 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
|
||||||
该手机号将作为门店端登录账号,提交前会再次校验是否已被占用。
|
该手机号将作为门店端登录账号。
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="partner-form-card">
|
|
||||||
|
|
||||||
<div className="partner-field">
|
|
||||||
|
|
||||||
<label>户主姓名 *</label>
|
|
||||||
|
|
||||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="partner-field">
|
|
||||||
|
|
||||||
<label>银行卡号 *</label>
|
|
||||||
|
|
||||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="partner-field">
|
|
||||||
|
|
||||||
<label>开户支行 *</label>
|
|
||||||
|
|
||||||
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
|
|
||||||
|
|
||||||
<span className="material-symbols-outlined text-secondary">info</span>
|
|
||||||
|
|
||||||
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
|
|
||||||
|
|
||||||
请确保银行卡信息准确,以免影响每月的餐费结算。
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<footer className={`partner-sticky-footer${subAccountLayout ? ' partner-sticky-footer--above-tabbar' : ''}`}>
|
<footer className="partner-sticky-footer">
|
||||||
|
|
||||||
{step > 1 && (
|
{step === 1 ? (
|
||||||
|
|
||||||
|
<button type="button" className="partner-btn-outline" onClick={() => navigate('/stores')} disabled={nextDisabled}>返回</button>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}>上一步</button>
|
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}>上一步</button>
|
||||||
|
|
||||||
@@ -1106,7 +1135,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
|
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
|
||||||
|
|
||||||
<span>{checkingPhone ? '校验中…' : '下一步'}</span>
|
<span>下一步</span>
|
||||||
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
||||||
|
|
||||||
@@ -1124,6 +1153,23 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<TencentLocPickerOverlay
|
||||||
|
open={mapPickerOpen}
|
||||||
|
onClose={() => setMapPickerOpen(false)}
|
||||||
|
latitude={form.latitude ? Number(form.latitude) : undefined}
|
||||||
|
longitude={form.longitude ? Number(form.longitude) : undefined}
|
||||||
|
onPick={(loc) => {
|
||||||
|
patchForm({
|
||||||
|
latitude: String(loc.latitude),
|
||||||
|
longitude: String(loc.longitude),
|
||||||
|
...(loc.address && !form.address.trim() ? { address: loc.address } : {}),
|
||||||
|
});
|
||||||
|
toastSuccess(
|
||||||
|
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `(${loc.name})` : ''}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { isSubAccount } from '../lib/partnerAccess';
|
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
|
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
|
||||||
|
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
|
||||||
|
import OssUploadField from '../components/OssUploadField';
|
||||||
|
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
@@ -16,19 +19,46 @@ import {
|
|||||||
} from '../lib/storeStatus';
|
} from '../lib/storeStatus';
|
||||||
|
|
||||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
||||||
|
const ENV_SLOT_COUNT = 3;
|
||||||
|
|
||||||
|
function uniqueEnvUrls(urls: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of urls) {
|
||||||
|
const url = raw.trim();
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StoreDetailPage() {
|
export default function StoreDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { account } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
const subReadonly = isSubAccount(account);
|
const canMutate = canManagePartnerStore(account);
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
intro: '',
|
||||||
|
benefitUsageRule: '',
|
||||||
|
latitude: '',
|
||||||
|
longitude: '',
|
||||||
|
});
|
||||||
|
const [coverUrl, setCoverUrl] = useState('');
|
||||||
|
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
|
||||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [mediaSaving, setMediaSaving] = useState(false);
|
||||||
|
const [locating, setLocating] = useState(false);
|
||||||
|
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
|
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||||
|
|
||||||
function applyStore(data: Record<string, unknown>) {
|
function applyStore(data: Record<string, unknown>) {
|
||||||
setStore(data);
|
setStore(data);
|
||||||
@@ -37,8 +67,23 @@ export default function StoreDetailPage() {
|
|||||||
phone: String(data.phone || ''),
|
phone: String(data.phone || ''),
|
||||||
address: String(data.address || ''),
|
address: String(data.address || ''),
|
||||||
intro: String(data.intro || ''),
|
intro: String(data.intro || ''),
|
||||||
|
benefitUsageRule: (() => {
|
||||||
|
const raw = String(data.benefitUsageRule || '').trim();
|
||||||
|
return raw && !/^null$/i.test(raw) ? raw : '';
|
||||||
|
})(),
|
||||||
|
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
|
||||||
|
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
|
||||||
});
|
});
|
||||||
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
||||||
|
setCoverUrl(String(data.coverUrl || ''));
|
||||||
|
const envFromMedia = Array.isArray(data.media)
|
||||||
|
? uniqueEnvUrls(
|
||||||
|
(data.media as Array<{ url?: string; bizType?: string }>)
|
||||||
|
.filter((m) => m.bizType === 'ENV')
|
||||||
|
.map((m) => String(m.url || '')),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,8 +110,8 @@ export default function StoreDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (next === 'CLOSED') {
|
if (next === 'CLOSED') {
|
||||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
setCloseConfirmOpen(true);
|
||||||
if (!ok) return;
|
return;
|
||||||
}
|
}
|
||||||
setStatusSaving(true);
|
setStatusSaving(true);
|
||||||
setActionError('');
|
setActionError('');
|
||||||
@@ -77,7 +122,27 @@ export default function StoreDetailPage() {
|
|||||||
});
|
});
|
||||||
setStatus(next);
|
setStatus(next);
|
||||||
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
||||||
toastSuccess('状态已更新');
|
toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新');
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||||
|
} finally {
|
||||||
|
setStatusSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCloseStore() {
|
||||||
|
if (!id || statusSaving) return;
|
||||||
|
setCloseConfirmOpen(false);
|
||||||
|
setStatusSaving(true);
|
||||||
|
setActionError('');
|
||||||
|
try {
|
||||||
|
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status: 'CLOSED' }),
|
||||||
|
});
|
||||||
|
setStatus('CLOSED');
|
||||||
|
setStore((prev) => (prev ? { ...prev, ...updated, status: 'CLOSED' } : prev));
|
||||||
|
toastSuccess('门店已关闭');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -102,6 +167,13 @@ export default function StoreDetailPage() {
|
|||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
intro: form.intro.trim(),
|
intro: form.intro.trim(),
|
||||||
|
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
||||||
|
...(form.latitude.trim() && form.longitude.trim()
|
||||||
|
? {
|
||||||
|
latitude: Number(form.latitude),
|
||||||
|
longitude: Number(form.longitude),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
applyStore(data);
|
applyStore(data);
|
||||||
@@ -113,10 +185,45 @@ export default function StoreDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveMedia() {
|
||||||
|
if (!id || mediaSaving || status === 'CLOSED') return;
|
||||||
|
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||||
|
if (auditStatus === 'PENDING') {
|
||||||
|
setActionError('门店审核中,暂不可修改资料');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextCover = coverUrl.trim();
|
||||||
|
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
||||||
|
if (!nextCover) {
|
||||||
|
setActionError('请上传门头照');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextEnv.length < ENV_SLOT_COUNT) {
|
||||||
|
setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMediaSaving(true);
|
||||||
|
setActionError('');
|
||||||
|
try {
|
||||||
|
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
coverUrl: nextCover,
|
||||||
|
envPhotoUrls: nextEnv,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
applyStore(data);
|
||||||
|
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(e instanceof Error ? e.message : '照片更新失败');
|
||||||
|
} finally {
|
||||||
|
setMediaSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="partner-detail-page">
|
<div className="partner-detail-page partner-home--flush-top">
|
||||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
|
||||||
<div className="empty">{loadError}</div>
|
<div className="empty">{loadError}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -124,20 +231,22 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
if (!store) return <div className="empty">加载中...</div>;
|
if (!store) return <div className="empty">加载中...</div>;
|
||||||
|
|
||||||
const envPhotos = Array.isArray(store.media)
|
const envPhotos = uniqueEnvUrls(
|
||||||
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
Array.isArray(store.media)
|
||||||
: [];
|
? (store.media as Array<{ url?: string; bizType?: string }>)
|
||||||
|
.filter((m) => m.bizType === 'ENV')
|
||||||
|
.map((m) => String(m.url || ''))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
||||||
const auditPending = auditStatus === 'PENDING';
|
const auditPending = auditStatus === 'PENDING';
|
||||||
const auditRejected = auditStatus === 'REJECTED';
|
const auditRejected = auditStatus === 'REJECTED';
|
||||||
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
|
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
|
||||||
const canOpen = canPartnerOpenStore(auditStatus);
|
const canOpen = canPartnerOpenStore(auditStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-detail-page">
|
<div className="partner-detail-page partner-home--flush-top">
|
||||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
<main style={{ padding: '12px 20px 16px' }}>
|
||||||
|
|
||||||
<main style={{ padding: '16px 20px' }}>
|
|
||||||
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
|
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
|
||||||
|
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
@@ -170,7 +279,7 @@ export default function StoreDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!subReadonly && (
|
{canMutate && (
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
||||||
@@ -200,13 +309,27 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
||||||
<div className="partner-cover">
|
{!canMutate || readOnly ? (
|
||||||
<AppImage
|
<div className="partner-cover">
|
||||||
src={store.coverUrl ? String(store.coverUrl) : null}
|
<AppImage
|
||||||
alt={form.name}
|
src={coverUrl || null}
|
||||||
wrapperClassName="app-image--fill"
|
alt={form.name}
|
||||||
/>
|
wrapperClassName="app-image--fill"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<p className="label-md text-muted" style={{ marginBottom: 8 }}>门头照</p>
|
||||||
|
<OssUploadField
|
||||||
|
wide
|
||||||
|
bizType="STORE_TITLE"
|
||||||
|
mediaType="IMAGE"
|
||||||
|
value={coverUrl}
|
||||||
|
onChange={setCoverUrl}
|
||||||
|
label="从系统相册选择"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>门店名称</label>
|
<label>门店名称</label>
|
||||||
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
|
||||||
@@ -221,26 +344,123 @@ export default function StoreDetailPage() {
|
|||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>门店地址</label>
|
<label>门店地址</label>
|
||||||
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||||
|
{!readOnly ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ padding: '8px 12px', fontSize: 13 }}
|
||||||
|
disabled={locating}
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
setLocating(true);
|
||||||
|
setActionError('');
|
||||||
|
try {
|
||||||
|
const pos = await locateStorePosition();
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
latitude: String(pos.latitude),
|
||||||
|
longitude: String(pos.longitude),
|
||||||
|
}));
|
||||||
|
toastSuccess('已获取当前坐标');
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : '定位失败';
|
||||||
|
setActionError(msg);
|
||||||
|
toastError(msg);
|
||||||
|
} finally {
|
||||||
|
setLocating(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locating ? '定位中…' : '获取当前位置'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ padding: '8px 12px', fontSize: 13 }}
|
||||||
|
onClick={() => setMapPickerOpen(true)}
|
||||||
|
>
|
||||||
|
地图选点
|
||||||
|
</button>
|
||||||
|
<span className="label-md text-muted">
|
||||||
|
{formatStoreCoords(form.latitude, form.longitude)
|
||||||
|
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
|
||||||
|
: '未定位'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : formatStoreCoords(form.latitude, form.longitude) ? (
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 6 }}>
|
||||||
|
坐标:{formatStoreCoords(form.latitude, form.longitude)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>门店简介</label>
|
<label>门店简介</label>
|
||||||
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(2-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="partner-field">
|
||||||
|
<label>好客权益券使用规则</label>
|
||||||
|
<textarea
|
||||||
|
disabled={readOnly}
|
||||||
|
rows={4}
|
||||||
|
placeholder="选填,展示在用户端门店详情,最多1000字"
|
||||||
|
maxLength={1000}
|
||||||
|
value={form.benefitUsageRule}
|
||||||
|
onChange={(e) => setForm({ ...form, benefitUsageRule: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||||
|
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
|
||||||
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}>店内环境</h3>
|
||||||
<span className="label-md text-muted">{envPhotos.length ? `已上传 ${envPhotos.length} 张` : '暂无照片'}</span>
|
<span className="label-md text-muted">
|
||||||
|
{canMutate && !readOnly
|
||||||
|
? `需 ${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
|
||||||
|
: envPhotos.length
|
||||||
|
? `已上传 ${envPhotos.length} 张`
|
||||||
|
: '暂无照片'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{envPhotos.length > 0 ? (
|
{canMutate && !readOnly ? (
|
||||||
|
<>
|
||||||
|
<div className="partner-upload-grid">
|
||||||
|
{envPhotoUrls.map((url, index) => (
|
||||||
|
<OssUploadField
|
||||||
|
key={index}
|
||||||
|
compact
|
||||||
|
bizType="STORE_ENV"
|
||||||
|
mediaType="IMAGE"
|
||||||
|
value={url}
|
||||||
|
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ width: '100%', marginTop: 16 }}
|
||||||
|
disabled={mediaSaving}
|
||||||
|
onClick={() => void saveMedia()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
|
||||||
|
upload
|
||||||
|
</span>
|
||||||
|
{mediaSaving ? '上传中…' : '重新上传照片'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : envPhotos.length > 0 ? (
|
||||||
<div className="partner-photo-grid">
|
<div className="partner-photo-grid">
|
||||||
{envPhotos.map((photo, index) => (
|
{envPhotos.map((url, index) => (
|
||||||
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
|
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
|
||||||
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,13 +484,50 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
<footer className="partner-save-footer">
|
<footer className="partner-save-footer">
|
||||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||||
{!subReadonly && (
|
{canMutate && (
|
||||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||||
<span className="material-symbols-outlined">save</span>
|
<span className="material-symbols-outlined">save</span>
|
||||||
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{closeConfirmOpen && (
|
||||||
|
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseConfirmOpen(false)}>
|
||||||
|
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||||
|
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||||
|
关闭后不可恢复营业,确认关闭该门店?
|
||||||
|
</p>
|
||||||
|
<div className="partner-ship-actions">
|
||||||
|
<button type="button" className="partner-btn-secondary" onClick={() => setCloseConfirmOpen(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||||
|
确认关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TencentLocPickerOverlay
|
||||||
|
open={mapPickerOpen}
|
||||||
|
onClose={() => setMapPickerOpen(false)}
|
||||||
|
latitude={form.latitude ? Number(form.latitude) : undefined}
|
||||||
|
longitude={form.longitude ? Number(form.longitude) : undefined}
|
||||||
|
onPick={(loc) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
latitude: String(loc.latitude),
|
||||||
|
longitude: String(loc.longitude),
|
||||||
|
...(loc.address && !prev.address.trim() ? { address: loc.address } : {}),
|
||||||
|
}));
|
||||||
|
toastSuccess(
|
||||||
|
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `(${loc.name})` : ''}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
import { toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { isSubAccount } from '../lib/partnerAccess';
|
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
@@ -25,13 +27,17 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
|
|||||||
|
|
||||||
export default function StoreListPage() {
|
export default function StoreListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { account } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
const readonly = isSubAccount(account);
|
const canMutate = canManagePartnerStore(account);
|
||||||
|
const canCreate = canCreatePartnerStore(account);
|
||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [filter, setFilter] = useState<StatusFilter>('ALL');
|
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
||||||
|
const [filter, setFilter] = useState<StatusFilter>(initialFilter);
|
||||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [closeTarget, setCloseTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadStores = useCallback(() => {
|
const loadStores = useCallback(() => {
|
||||||
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||||
@@ -42,6 +48,10 @@ export default function StoreListPage() {
|
|||||||
void loadStores();
|
void loadStores();
|
||||||
}, [navigate, loadStores]);
|
}, [navigate, loadStores]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = canMutate ? '门店管理' : '我的门店';
|
||||||
|
}, [canMutate]);
|
||||||
|
|
||||||
const filtered = useMemo(() => stores.filter((s) => {
|
const filtered = useMemo(() => stores.filter((s) => {
|
||||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||||
@@ -59,8 +69,8 @@ export default function StoreListPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (next === 'CLOSED') {
|
if (next === 'CLOSED') {
|
||||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
setCloseTarget(storeId);
|
||||||
if (!ok) return;
|
return;
|
||||||
}
|
}
|
||||||
setUpdatingId(storeId);
|
setUpdatingId(storeId);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -70,6 +80,26 @@ export default function StoreListPage() {
|
|||||||
body: JSON.stringify({ status: next }),
|
body: JSON.stringify({ status: next }),
|
||||||
});
|
});
|
||||||
await loadStores();
|
await loadStores();
|
||||||
|
if (next === 'OPEN') toastSuccess('开店成功');
|
||||||
|
} catch {
|
||||||
|
/* request 已 toast */
|
||||||
|
} finally {
|
||||||
|
setUpdatingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCloseStore() {
|
||||||
|
if (!closeTarget) return;
|
||||||
|
const storeId = closeTarget;
|
||||||
|
setCloseTarget(null);
|
||||||
|
setUpdatingId(storeId);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status: 'CLOSED' }),
|
||||||
|
});
|
||||||
|
await loadStores();
|
||||||
} catch {
|
} catch {
|
||||||
/* request 已 toast */
|
/* request 已 toast */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -78,16 +108,7 @@ export default function StoreListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page partner-store-page">
|
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
||||||
<header className="header app-page-header">
|
|
||||||
<h1 className="app-page-title">杜康好客</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="partner-page-title-block">
|
|
||||||
<h2>{readonly ? '我的门店' : '门店管理'}</h2>
|
|
||||||
<p className="text-muted body-md">{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||||
|
|
||||||
<div className="partner-sticky-filter">
|
<div className="partner-sticky-filter">
|
||||||
@@ -104,12 +125,14 @@ export default function StoreListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canCreate && (
|
||||||
<Link to="/stores/new" className="partner-fab-link">
|
<Link to="/stores/new" className="partner-fab-link">
|
||||||
<button type="button" className="partner-btn-primary">
|
<button type="button" className="partner-btn-primary">
|
||||||
<span className="material-symbols-outlined">add_business</span>
|
<span className="material-symbols-outlined">add_business</span>
|
||||||
录入新门店
|
录入新门店
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||||
|
|
||||||
@@ -149,7 +172,7 @@ export default function StoreListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{!readonly && (
|
{canMutate && (
|
||||||
<div className="partner-store-card-actions">
|
<div className="partner-store-card-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -188,6 +211,24 @@ export default function StoreListPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
{closeTarget && (
|
||||||
|
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
|
||||||
|
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||||
|
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||||
|
关闭后不可恢复营业,确认关闭该门店?
|
||||||
|
</p>
|
||||||
|
<div className="partner-ship-actions">
|
||||||
|
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||||
|
确认关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
|
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
|
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
|
||||||
|
|
||||||
function fmtMoney(n: number) {
|
function fmtMoney(n: number) {
|
||||||
@@ -20,10 +21,10 @@ export default function WeeklyReportPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
const loadWeeklyReport = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
fetchPartnerWeeklyReport(selectedStart)
|
return fetchPartnerWeeklyReport(selectedStart)
|
||||||
.then(setData)
|
.then(setData)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setData(null);
|
setData(null);
|
||||||
@@ -32,6 +33,10 @@ export default function WeeklyReportPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [selectedStart]);
|
}, [selectedStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadWeeklyReport();
|
||||||
|
}, [loadWeeklyReport]);
|
||||||
|
|
||||||
const maxDailyGmv = useMemo(() => {
|
const maxDailyGmv = useMemo(() => {
|
||||||
if (!data?.dailyGmv.length) return 1;
|
if (!data?.dailyGmv.length) return 1;
|
||||||
return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
|
return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
|
||||||
@@ -42,7 +47,7 @@ export default function WeeklyReportPage() {
|
|||||||
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
|
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab">
|
<PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab">
|
||||||
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
||||||
|
|
||||||
<main className="partner-weekly-page">
|
<main className="partner-weekly-page">
|
||||||
@@ -211,6 +216,6 @@ export default function WeeklyReportPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+559
-34
@@ -13,13 +13,23 @@ html {
|
|||||||
/* ── Partner auth ── */
|
/* ── Partner auth ── */
|
||||||
.partner-auth-page {
|
.partner-auth-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
padding: 48px var(--space-page) var(--space-page);
|
padding: 48px var(--space-page) calc(24px + env(safe-area-inset-bottom, 0px));
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
|
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-height: 720px) {
|
||||||
|
.partner-auth-page {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-auth-page--quick {
|
.partner-auth-page--quick {
|
||||||
@@ -170,10 +180,23 @@ html {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: var(--space-md);
|
margin-top: var(--space-md);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
color: var(--color-on-surface-variant);
|
color: var(--color-on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-checkbox-row input { margin-top: 2px; accent-color: var(--color-heritage-red); }
|
.partner-checkbox-row input {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-checkbox-row--agreement {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.partner-auth-footer {
|
.partner-auth-footer {
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
@@ -621,9 +644,6 @@ html {
|
|||||||
|
|
||||||
.partner-expansion-split {
|
.partner-expansion-split {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 1px solid rgba(226, 190, 188, 0.2);
|
|
||||||
padding-bottom: var(--space-md);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-expansion-split > div {
|
.partner-expansion-split > div {
|
||||||
@@ -635,6 +655,28 @@ html {
|
|||||||
border-left: 1px solid rgba(226, 190, 188, 0.2);
|
border-left: 1px solid rgba(226, 190, 188, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-expansion-value-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-expansion-trend {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-expansion-trend--up {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-expansion-trend--down {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
.partner-form-error {
|
.partner-form-error {
|
||||||
margin: 0 20px 12px;
|
margin: 0 20px 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -845,6 +887,57 @@ html {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-leaderboard-preview .partner-leaderboard-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-leaderboard-avatar {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface-container-low);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-leaderboard-avatar .material-symbols-outlined {
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
font-variation-settings: 'FILL' 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-leaderboard-avatar-badge {
|
||||||
|
position: absolute;
|
||||||
|
left: -2px;
|
||||||
|
top: -2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #9ca3af;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 0 2px #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-leaderboard-avatar--rank-1 .partner-leaderboard-avatar-badge {
|
||||||
|
background: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-leaderboard-avatar--rank-2 .partner-leaderboard-avatar-badge {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-leaderboard-avatar--rank-3 .partner-leaderboard-avatar-badge {
|
||||||
|
background: #cd7f32;
|
||||||
|
}
|
||||||
|
|
||||||
.partner-leaderboard-info {
|
.partner-leaderboard-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1296,7 +1389,7 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
|
|
||||||
.partner-sticky-footer--above-tabbar {
|
.partner-sticky-footer--above-tabbar {
|
||||||
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||||
z-index: 90;
|
z-index: 110;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-stepper {
|
.partner-stepper {
|
||||||
@@ -1752,12 +1845,13 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
transform: none;
|
transform: none;
|
||||||
background: rgba(250, 249, 247, 0.92);
|
background: rgba(250, 249, 247, 0.96);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border-top: 1px solid rgba(226, 190, 188, 0.2);
|
border-top: 1px solid rgba(226, 190, 188, 0.2);
|
||||||
box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05);
|
box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05);
|
||||||
padding: 16px var(--space-page) calc(16px + env(safe-area-inset-bottom, 0px));
|
padding: 16px var(--space-page) calc(16px + env(safe-area-inset-bottom, 0px));
|
||||||
z-index: 60;
|
/* 高于 .app-tabbar(100),防止偶发叠层遮挡「下一步/提交」 */
|
||||||
|
z-index: 110;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1801,7 +1895,7 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
|
|
||||||
.partner-sticky-filter {
|
.partner-sticky-filter {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 56px;
|
top: 0;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
background: rgba(250, 249, 247, 0.95);
|
background: rgba(250, 249, 247, 0.95);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
@@ -2401,6 +2495,14 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-profile-avatar:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-profile-avatar .material-symbols-outlined {
|
.partner-profile-avatar .material-symbols-outlined {
|
||||||
@@ -2409,22 +2511,81 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
font-variation-settings: 'FILL' 1;
|
font-variation-settings: 'FILL' 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-profile-avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-profile-avatar-hint {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(166, 29, 36, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-profile-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-profile-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-profile-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-profile-location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
}
|
||||||
|
|
||||||
.partner-role-badge {
|
.partner-role-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
background: var(--color-secondary-container);
|
background: var(--color-secondary-container);
|
||||||
color: var(--color-on-secondary-container);
|
color: var(--color-on-secondary-container);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: var(--font-label);
|
font-family: var(--font-label);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-assets-section {
|
||||||
|
margin-top: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-bills-banner {
|
.partner-bills-banner {
|
||||||
margin: 0 var(--space-page) 12px;
|
margin: 0 var(--space-page) 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: rgba(166, 29, 36, 0.05);
|
background: rgba(166, 29, 36, 0.06);
|
||||||
border: 1px solid rgba(166, 29, 36, 0.1);
|
border: 1px solid rgba(166, 29, 36, 0.12);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2463,13 +2624,100 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
border: 1px solid rgba(166, 29, 36, 0.05);
|
border: 1px solid rgba(166, 29, 36, 0.05);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-finance-card--hero {
|
.partner-finance-card--hero {
|
||||||
grid-column: span 2;
|
grid-column: 1 / -1;
|
||||||
background: var(--color-heritage-red);
|
background: linear-gradient(135deg, #a61d24 0%, #820012 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25);
|
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25);
|
||||||
|
border: none;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-finance-hero-label {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.88;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-finance-hero-amount {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-finance-value {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0 var(--space-page) 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-menu-section .partner-section-head {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-menu-item-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-menu-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
background: rgba(166, 29, 36, 0.08);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-menu-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-logout-btn {
|
||||||
|
width: calc(100% - 2 * var(--space-page));
|
||||||
|
margin: var(--space-lg) var(--space-page) 0;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(166, 29, 36, 0.08);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-logout-btn .material-symbols-outlined {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-center-footer {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.35;
|
||||||
|
padding: 24px 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-menu-section {
|
.partner-menu-section {
|
||||||
@@ -2488,14 +2736,28 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
border: none;
|
||||||
border-bottom: 1px solid var(--color-surface-container);
|
border-bottom: 1px solid var(--color-surface-container);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-menu-item:last-child { border-bottom: none; }
|
.partner-menu-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.partner-menu-item:active {
|
||||||
|
background: rgba(166, 29, 36, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.partner-menu-item-left {
|
.partner-menu-item-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2513,24 +2775,6 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.partner-logout-btn {
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
margin: var(--space-lg) var(--space-page);
|
|
||||||
padding: 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-card);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
color: var(--color-error);
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Settlement page ── */
|
/* ── Settlement page ── */
|
||||||
.partner-settlement-page {
|
.partner-settlement-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -2778,6 +3022,11 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
color: var(--color-success-green);
|
color: var(--color-success-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-settlement-status--rejected {
|
||||||
|
background: rgba(166, 29, 36, 0.12);
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
.partner-settlement-item-meta {
|
.partner-settlement-item-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3065,3 +3314,279 @@ body {
|
|||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px dashed rgba(166, 29, 36, 0.15);
|
border-top: 1px dashed rgba(166, 29, 36, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.partner-proxy-mode-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-mode-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-ink-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-mode-tab.is-active {
|
||||||
|
border-color: var(--color-heritage-red);
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
background: rgba(166, 29, 36, 0.06);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-auto-receive {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-ink-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-auto-receive input {
|
||||||
|
margin-top: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-picker {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 36px 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-picker-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--color-muted, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-list {
|
||||||
|
padding: 0 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-card--active {
|
||||||
|
border-color: var(--color-heritage-red);
|
||||||
|
box-shadow: 0 0 0 1px rgba(166, 29, 36, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-thumb {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f3f1ee;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-thumb-empty {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #f3f1ee, #e8e4de);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-ink-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-price {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-product-spec,
|
||||||
|
.partner-proxy-product-benefit {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-proxy-confirm {
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-warehouse-denied {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-warehouse-denied .material-symbols-outlined {
|
||||||
|
font-size: 36px;
|
||||||
|
font-variation-settings: 'FILL' 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-home--flush-top.partner-store-page,
|
||||||
|
.partner-home--flush-top.partner-center-page,
|
||||||
|
.partner-home--flush-top.partner-detail-page,
|
||||||
|
.partner-home--flush-top.partner-page-sticky {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-home--flush-top.partner-page-sticky .partner-stepper {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-home--flush-top .partner-sticky-filter {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-leaderboard-self-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
background: rgba(166, 29, 36, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-order-card-wrap {
|
||||||
|
margin: 0 var(--space-page) 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-order-ship-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: -4px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
background: var(--color-heritage-red);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-order-ship-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-order-footer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-order-ship-btn--footer {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-ship-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-ship-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-ship-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-ship-field input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-outline-variant);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-ship-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-ship-actions .partner-btn-secondary,
|
||||||
|
.partner-ship-actions .partner-btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 微信 H5 系统标题已展示:隐藏页内重复标题,保留返回键与操作区 */
|
||||||
|
.app-page-title {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 仅含标题的顶栏一并收起,去掉空白条;有返回/操作的顶栏保留 */
|
||||||
|
header:has(> .app-page-title:only-child),
|
||||||
|
.header.app-page-header:has(> .app-page-title:only-child),
|
||||||
|
.app-page-header:has(> .app-page-title:only-child) {
|
||||||
|
display: none !important;
|
||||||
|
height: 0 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
overflow: hidden;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
.legal-h5-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #faf9f7;
|
||||||
|
color: #1f1a17;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-h5-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #faf9f7;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-h5-back {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #1f1a17;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-h5-title {
|
||||||
|
margin: 0;
|
||||||
|
/* 与微信系统标题重复,仅保留顶栏高度与返回 */
|
||||||
|
visibility: hidden;
|
||||||
|
font-size: 0;
|
||||||
|
line-height: 0;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-h5-body {
|
||||||
|
padding: 16px 20px 40px;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-h5-updated {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8d706e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-h5-intro {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-h5-section {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-h5-section h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-h5-section p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #5c504c;
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/partner/',
|
// 独立域名 partner.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
|
||||||
|
base: process.env.VITE_PUBLIC_BASE ?? '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import AuthGate from './components/AuthGate';
|
import AuthGate from './components/AuthGate';
|
||||||
import TabLayout from './layouts/TabLayout';
|
import TabLayout from './layouts/TabLayout';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import LegalPage from './pages/LegalPage';
|
||||||
import SelectStorePage from './pages/SelectStorePage';
|
import SelectStorePage from './pages/SelectStorePage';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
|
||||||
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
||||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||||
import RecordsPage from './pages/RecordsPage';
|
import RecordsPage from './pages/RecordsPage';
|
||||||
@@ -17,9 +17,11 @@ export default function App() {
|
|||||||
<AuthGate>
|
<AuthGate>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
|
||||||
|
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||||
<Route path="/select-store" element={<SelectStorePage />} />
|
<Route path="/select-store" element={<SelectStorePage />} />
|
||||||
<Route path="/staff" element={<StaffPage />} />
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
<Route path="/redeem" element={<Navigate to="/redeem/phone" replace />} />
|
||||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||||
<Route element={<TabLayout />}>
|
<Route element={<TabLayout />}>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set(['/login']);
|
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||||
const SELECT_STORE_PATH = '/select-store';
|
const SELECT_STORE_PATH = '/select-store';
|
||||||
|
|
||||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
@@ -26,10 +25,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||||
const profile = getStoreProfile();
|
|
||||||
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
|
||||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
|
||||||
}
|
|
||||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
const [photoResourceId, setPhotoResourceId] = useState('');
|
const [photoResourceId, setPhotoResourceId] = useState('');
|
||||||
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
|
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -49,7 +50,11 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = e instanceof Error ? e.message : '选图失败';
|
const text = e instanceof Error ? e.message : '选图失败';
|
||||||
if (!/cancel/i.test(text)) setMsg(text);
|
if (!/cancel/i.test(text)) {
|
||||||
|
setMsg(`${text},可改从系统相册选择`);
|
||||||
|
setShowAlbumFallback(true);
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -120,11 +125,13 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
|
||||||
hidden
|
hidden
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) void handleFile(file);
|
if (file) {
|
||||||
|
setShowAlbumFallback(false);
|
||||||
|
void handleFile(file);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
@@ -134,6 +141,16 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
|
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
|
||||||
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
|
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
|
||||||
</button>
|
</button>
|
||||||
|
{showAlbumFallback && isWechatEnv() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-btn-outline"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
从系统相册选择
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-redeem-confirm-btn"
|
className="shop-redeem-confirm-btn"
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const STORE_PROFILE = 'shopStoreProfile';
|
|||||||
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
|
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
|
||||||
export const SHOP_WX_BOUND = 'shopWxBound';
|
export const SHOP_WX_BOUND = 'shopWxBound';
|
||||||
|
|
||||||
/** 微信验证通过后的免登录时长 */
|
/** 手机号或微信验证通过后的免登录时长 */
|
||||||
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||||
@@ -87,7 +87,7 @@ export function isShopSessionExpired() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function touchShopSession() {
|
export function touchShopSession() {
|
||||||
if (!hasShopWxSession()) return;
|
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +119,16 @@ export function saveAuth(data: ShopSessionPayload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||||||
|
export function saveRememberedSession(data: ShopSessionPayload) {
|
||||||
|
saveAuth(data);
|
||||||
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||||
|
}
|
||||||
|
|
||||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||||
export function saveWechatSession(data: ShopSessionPayload) {
|
export function saveWechatSession(data: ShopSessionPayload) {
|
||||||
saveAuth(data);
|
saveRememberedSession(data);
|
||||||
localStorage.setItem(SHOP_WX_BOUND, '1');
|
localStorage.setItem(SHOP_WX_BOUND, '1');
|
||||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type ShopAccountProfile = {
|
|||||||
storeId: string;
|
storeId: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
/** 是否已绑定微信(来自 /shop/auth/me account.hasWechat) */
|
||||||
|
hasWechat?: boolean;
|
||||||
wxOpenId?: string | null;
|
wxOpenId?: string | null;
|
||||||
store?: { name: string };
|
store?: { name: string };
|
||||||
};
|
};
|
||||||
@@ -18,7 +20,27 @@ export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchShopAccount(): Promise<ShopAccountProfile> {
|
export async function fetchShopAccount(): Promise<ShopAccountProfile> {
|
||||||
return request<ShopAccountProfile>('SHOP_H5', '/shop/auth/me');
|
const me = await request<{
|
||||||
|
id?: string;
|
||||||
|
storeId?: string;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
account?: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
hasWechat?: boolean;
|
||||||
|
};
|
||||||
|
store?: { name?: string } | null;
|
||||||
|
}>('SHOP_H5', '/shop/auth/me');
|
||||||
|
return {
|
||||||
|
id: String(me.account?.id ?? me.id ?? ''),
|
||||||
|
storeId: String(me.storeId ?? ''),
|
||||||
|
name: String(me.account?.name ?? me.name ?? ''),
|
||||||
|
phone: String(me.account?.phone ?? me.phone ?? ''),
|
||||||
|
hasWechat: !!me.account?.hasWechat,
|
||||||
|
store: me.store?.name ? { name: String(me.store.name) } : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function needsWechatAuth(
|
export function needsWechatAuth(
|
||||||
@@ -26,7 +48,8 @@ export function needsWechatAuth(
|
|||||||
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (config && !isWxAuthorizeEnabled(config)) return false;
|
if (config && !isWxAuthorizeEnabled(config)) return false;
|
||||||
return isWechatEnv() && !!profile && !profile.wxOpenId;
|
const unbound = !!profile && !(profile.hasWechat || profile.wxOpenId);
|
||||||
|
return isWechatEnv() && unbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<boolean> {
|
export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<boolean> {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getRouterBasename } from '@dukang/weixin-sdk';
|
|||||||
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
import './styles/legal.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -1,141 +1,58 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
|
||||||
import {
|
|
||||||
authorizeShopWechat,
|
|
||||||
checkNeedsWechatAuth,
|
|
||||||
fetchShopAccount,
|
|
||||||
handleShopWechatCallback,
|
|
||||||
handleShopWechatLoginResult,
|
|
||||||
} from '../lib/wechat-auth';
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
|
||||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
|
||||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
|
||||||
|
|
||||||
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
|
||||||
|
|
||||||
function formatMoney(n: number) {
|
function formatMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatScanError(e: unknown): string {
|
|
||||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
|
||||||
if (/invalid signature/i.test(msg)) {
|
|
||||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
|
||||||
}
|
|
||||||
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
|
||||||
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession } = useStoreSession();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
const [scanMsg, setScanMsg] = useState('');
|
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
|
||||||
const [authError, setAuthError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
const loadDashboard = useCallback(() => {
|
||||||
request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard').then((d) => {
|
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||||
setDash(d);
|
.then((d) => {
|
||||||
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
|
setDash(d);
|
||||||
});
|
})
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWechatEnv() || !searchParams.get('code')) return;
|
void loadDashboard();
|
||||||
void handleShopWechatCallback()
|
}, [loadDashboard]);
|
||||||
.then((result) => {
|
|
||||||
if (!result) return;
|
|
||||||
const session = handleShopWechatLoginResult(result);
|
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
}
|
|
||||||
setAuthModalOpen(false);
|
|
||||||
setAuthError('');
|
|
||||||
stripOAuthParamsFromLocation();
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
if (shouldScan) {
|
|
||||||
window.setTimeout(() => void runScan(), 0);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
|
||||||
});
|
|
||||||
}, [searchParams, applySession, setSearchParams]);
|
|
||||||
|
|
||||||
async function runScan() {
|
useEffect(() => {
|
||||||
if (!isWechatEnv()) {
|
function onResume() {
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
void loadDashboard();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setScanning(true);
|
function onVisibility() {
|
||||||
setScanMsg('');
|
if (document.visibilityState === 'visible') onResume();
|
||||||
try {
|
|
||||||
await weixinSdk.init();
|
|
||||||
const raw = await weixinSdk.scanQrCode();
|
|
||||||
if (!raw) return;
|
|
||||||
const token = parseRedeemTokenFromScan(raw);
|
|
||||||
if (!token) {
|
|
||||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
|
||||||
} catch (e) {
|
|
||||||
setScanMsg(formatScanError(e));
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
}
|
}
|
||||||
}
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
window.addEventListener('pageshow', onResume);
|
||||||
async function handleScan() {
|
window.addEventListener('focus', onResume);
|
||||||
setScanMsg('');
|
return () => {
|
||||||
if (!isWechatEnv()) {
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
window.removeEventListener('pageshow', onResume);
|
||||||
return;
|
window.removeEventListener('focus', onResume);
|
||||||
}
|
};
|
||||||
try {
|
}, [loadDashboard]);
|
||||||
const profile = await fetchShopAccount();
|
|
||||||
if (await checkNeedsWechatAuth(profile)) {
|
|
||||||
setAuthModalOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runScan();
|
|
||||||
} catch (e) {
|
|
||||||
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startWechatAuth() {
|
|
||||||
setAuthLoading(true);
|
|
||||||
setAuthError('');
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
|
||||||
await authorizeShopWechat();
|
|
||||||
} catch (e) {
|
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
|
||||||
setAuthLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = dash?.store as Record<string, unknown> | undefined;
|
const store = dash?.store as Record<string, unknown> | undefined;
|
||||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||||
const openTime = String(store?.openTime || '10:00');
|
const status = String(store?.status || '');
|
||||||
const closeTime = String(store?.closeTime || '22:00');
|
const open = status === 'OPEN';
|
||||||
|
const hoursParts: string[] = [];
|
||||||
|
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||||
|
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||||
|
const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00';
|
||||||
|
const statusText =
|
||||||
|
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-home-page">
|
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
||||||
<header className="shop-home-header">
|
<header className="shop-home-header">
|
||||||
<h1 className="app-page-title">门店管理中心</h1>
|
<h1 className="app-page-title">门店管理中心</h1>
|
||||||
</header>
|
</header>
|
||||||
@@ -162,20 +79,13 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-scan">
|
<section className="shop-home-scan">
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/redeem/phone"
|
||||||
className="shop-home-scan-btn"
|
className="shop-home-scan-btn"
|
||||||
disabled={scanning}
|
|
||||||
onClick={() => void handleScan()}
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
|
||||||
</button>
|
|
||||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
|
||||||
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
|
||||||
<Link to="/redeem/phone" className="shop-home-phone-link">
|
|
||||||
<span className="material-symbols-outlined">smartphone</span>
|
<span className="material-symbols-outlined">smartphone</span>
|
||||||
手机号核销
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<p className="shop-home-scan-label">手机号核销</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-status">
|
<section className="shop-home-status">
|
||||||
@@ -185,12 +95,12 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-home-status-title">营业状态</p>
|
<p className="shop-home-status-title">营业状态</p>
|
||||||
<p className="shop-home-status-sub">{open ? '当前正在营业中' : '当前已停止营业'}</p>
|
<p className="shop-home-status-sub">{statusText}</p>
|
||||||
<p className="shop-home-status-sub">营业时间: {openTime} - {closeTime}</p>
|
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
||||||
<input type="checkbox" checked={open} readOnly tabIndex={-1} />
|
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
|
||||||
<span className="shop-home-switch-track" />
|
<span className="shop-home-switch-track" />
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
@@ -222,17 +132,6 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WechatScanAuthModal
|
</PullToRefresh>
|
||||||
open={authModalOpen}
|
|
||||||
loading={authLoading}
|
|
||||||
error={authError}
|
|
||||||
onAuthorize={() => void startWechatAuth()}
|
|
||||||
onCancel={() => {
|
|
||||||
setAuthModalOpen(false);
|
|
||||||
setAuthError('');
|
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
type LegalPageProps = {
|
||||||
|
docId: LegalDocument['id'];
|
||||||
|
/** 返回登录页的路径,如 /login */
|
||||||
|
backTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** H5 各端共用的协议/隐私正文页 */
|
||||||
|
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
|
||||||
|
const doc = getLegalDocument(docId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="legal-h5-page">
|
||||||
|
<header className="legal-h5-header">
|
||||||
|
<Link to={backTo} className="legal-h5-back" aria-label="返回">
|
||||||
|
‹
|
||||||
|
</Link>
|
||||||
|
<h1 className="legal-h5-title">{doc.title}</h1>
|
||||||
|
</header>
|
||||||
|
<main className="legal-h5-body">
|
||||||
|
<p className="legal-h5-updated">更新日期:{doc.updatedAt}</p>
|
||||||
|
<p className="legal-h5-intro">{doc.intro}</p>
|
||||||
|
{doc.sections.map((section) => (
|
||||||
|
<section key={section.heading} className="legal-h5-section">
|
||||||
|
<h2>{section.heading}</h2>
|
||||||
|
{section.paragraphs.map((p, i) => (
|
||||||
|
<p key={`${section.heading}-${i}`}>{p}</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,76 +1,60 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useRef, useState, type RefObject } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
|
||||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
|
|
||||||
import { routeAfterShopLogin } from './SelectStorePage';
|
|
||||||
import {
|
import {
|
||||||
bindShopWechatAfterSmsLogin,
|
getLastPhone,
|
||||||
fetchClientConfig,
|
request,
|
||||||
handleShopWechatCallback,
|
saveRememberedSession,
|
||||||
handleShopWechatLoginResult,
|
type ShopSessionPayload,
|
||||||
loginShopWithWechat,
|
} from '../lib/api';
|
||||||
} from '../lib/wechat-auth';
|
import { routeAfterShopLogin } from './SelectStorePage';
|
||||||
import { isWechatEnv } from '../lib/weixin';
|
|
||||||
|
|
||||||
function maskPhone(phone: string) {
|
function ShopAgreementCheckbox({
|
||||||
if (phone.length < 7) return phone;
|
agreed,
|
||||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
onChange,
|
||||||
}
|
labelRef,
|
||||||
|
}: {
|
||||||
function formatWechatError(e: unknown): string {
|
agreed: boolean;
|
||||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
onChange: (next: boolean) => void;
|
||||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
labelRef?: RefObject<HTMLLabelElement>;
|
||||||
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信';
|
}) {
|
||||||
}
|
return (
|
||||||
return text;
|
<label className="shop-login-agreement" ref={labelRef}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
我已阅读并同意
|
||||||
|
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《用户协议》
|
||||||
|
</Link>
|
||||||
|
与
|
||||||
|
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
|
||||||
|
《隐私政策》
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession } = useStoreSession();
|
const { applySession } = useStoreSession();
|
||||||
const [params, setSearchParams] = useSearchParams();
|
|
||||||
const quick = params.get('quick') === '1';
|
|
||||||
const savedProfile = getStoreProfile();
|
|
||||||
const [phone, setPhone] = useState(getLastPhone());
|
const [phone, setPhone] = useState(getLastPhone());
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [agreed, setAgreed] = useState(true);
|
const [agreed, setAgreed] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [wxLoading, setWxLoading] = useState(false);
|
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchClientConfig()
|
|
||||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
|
||||||
.catch(() => setWxAuthorize(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
|
||||||
void handleShopWechatCallback()
|
|
||||||
.then((result) => {
|
|
||||||
if (!result) return;
|
|
||||||
const session = handleShopWechatLoginResult(result);
|
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
stripOAuthParamsFromLocation();
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
routeAfterShopLogin(session, navigate);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => setMsg(formatWechatError(e)));
|
|
||||||
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
|
|
||||||
|
|
||||||
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
|
|
||||||
const quickPhone = savedProfile?.phone || phone;
|
|
||||||
|
|
||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先阅读并同意用户协议');
|
setMsg('请先阅读并同意用户协议');
|
||||||
|
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -109,13 +93,8 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone, code }),
|
||||||
});
|
});
|
||||||
saveAuth(data);
|
saveRememberedSession(data);
|
||||||
applySession(data);
|
applySession(data);
|
||||||
if (isWechatEnv() && wxAuthorize) {
|
|
||||||
setMsg('登录成功,正在关联微信…');
|
|
||||||
await bindShopWechatAfterSmsLogin();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
routeAfterShopLogin(data, navigate);
|
routeAfterShopLogin(data, navigate);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||||
@@ -124,98 +103,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wechatLogin() {
|
|
||||||
if (!ensureAgreed()) return;
|
|
||||||
setMsg('');
|
|
||||||
if (!isWechatEnv()) {
|
|
||||||
setMsg('请在微信内打开以使用微信一键登录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setWxLoading(true);
|
|
||||||
try {
|
|
||||||
const session = await loginShopWithWechat();
|
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
routeAfterShopLogin(session, navigate);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(formatWechatError(e));
|
|
||||||
} finally {
|
|
||||||
setWxLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quick) {
|
|
||||||
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shop-quick-login-page">
|
|
||||||
<header className="shop-quick-header">
|
|
||||||
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="shop-quick-logo" fit="contain" />
|
|
||||||
<h1 className="shop-quick-welcome">欢迎回来</h1>
|
|
||||||
<div className="shop-quick-welcome-line" />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="shop-quick-store-card">
|
|
||||||
<div className="shop-quick-store-inner">
|
|
||||||
<div className="shop-quick-store-icon">
|
|
||||||
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
|
|
||||||
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
|
|
||||||
</div>
|
|
||||||
<span className="shop-quick-verified">
|
|
||||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
|
|
||||||
认证门店
|
|
||||||
</span>
|
|
||||||
<div className="shop-quick-switch">
|
|
||||||
<Link to="/login">
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>sync</span>
|
|
||||||
切换账号
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="shop-quick-actions">
|
|
||||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
|
||||||
{canWechatQuick ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-quick-login-btn shop-quick-login-btn--wechat"
|
|
||||||
disabled={wxLoading}
|
|
||||||
onClick={() => void wechatLogin()}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined">chat</span>
|
|
||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
|
|
||||||
{wxAuthorize && !isWechatEnv()
|
|
||||||
? '请在微信内打开以使用一键登录'
|
|
||||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!canWechatQuick && (
|
|
||||||
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
|
||||||
验证码登录
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<div className="shop-quick-secure">
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
|
|
||||||
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer className="shop-quick-footer">
|
|
||||||
<p className="shop-login-footer-brand">SECURED BY DUKANG HERITAGE</p>
|
|
||||||
<p style={{ fontSize: 10, fontFamily: 'var(--font-label)' }}>© 2024 杜康酒业门店管理系统</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-login-page">
|
<div className="shop-login-page">
|
||||||
<header className="shop-login-hero">
|
<header className="shop-login-hero">
|
||||||
@@ -270,6 +157,12 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||||
|
|
||||||
|
<ShopAgreementCheckbox
|
||||||
|
agreed={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
labelRef={agreementRef}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-login-submit"
|
className="shop-login-submit"
|
||||||
@@ -279,41 +172,10 @@ export default function LoginPage() {
|
|||||||
<span>{loading ? '登录中...' : '登录'}</span>
|
<span>{loading ? '登录中...' : '登录'}</span>
|
||||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="shop-login-msg" style={{ textAlign: 'center', marginTop: 12 }}>
|
||||||
{wxAuthorize && (
|
手机号验证成功后,7 天内无需再次输入验证码
|
||||||
<>
|
</p>
|
||||||
<div className="shop-login-divider">
|
|
||||||
<span className="shop-login-divider-line" />
|
|
||||||
<span className="shop-login-divider-text">或者</span>
|
|
||||||
<span className="shop-login-divider-line" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-login-wechat"
|
|
||||||
disabled={wxLoading}
|
|
||||||
onClick={() => void wechatLogin()}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined">chat</span>
|
|
||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="shop-login-agreement">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={agreed}
|
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
我已阅读并同意
|
|
||||||
<a href="#user-agreement">《用户协议》</a>
|
|
||||||
与
|
|
||||||
<a href="#privacy">《隐私政策》</a>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="shop-login-footer">
|
<footer className="shop-login-footer">
|
||||||
@@ -321,11 +183,6 @@ export default function LoginPage() {
|
|||||||
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
|
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
|
||||||
security
|
security
|
||||||
</span>
|
</span>
|
||||||
{hasShopWxSession() && savedProfile && (
|
|
||||||
<p style={{ marginTop: 16, textAlign: 'center' }}>
|
|
||||||
<Link to="/login?quick=1" className="text-primary body-md">微信快捷登录</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,101 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import { getStoreProfile, request } from '../lib/api';
|
import { getStoreProfile, request } from '../lib/api';
|
||||||
|
import {
|
||||||
|
authorizeShopWechat,
|
||||||
|
fetchClientConfig,
|
||||||
|
fetchShopAccount,
|
||||||
|
handleShopWechatCallback,
|
||||||
|
handleShopWechatLoginResult,
|
||||||
|
} from '../lib/wechat-auth';
|
||||||
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
|
|
||||||
export default function MinePage() {
|
export default function MinePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { resetSession, store: sessionStore } = useStoreSession();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { resetSession, store: sessionStore, applySession } = useStoreSession();
|
||||||
const profile = sessionStore ?? getStoreProfile();
|
const profile = sessionStore ?? getStoreProfile();
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [hasWechat, setHasWechat] = useState<boolean | null>(null);
|
||||||
|
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||||
|
const [binding, setBinding] = useState(false);
|
||||||
|
const [bindMsg, setBindMsg] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
const loadMine = useCallback(() => {
|
||||||
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
|
return Promise.all([
|
||||||
|
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)),
|
||||||
|
fetchClientConfig()
|
||||||
|
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||||
|
.catch(() => setWxAuthorize(false)),
|
||||||
|
fetchShopAccount()
|
||||||
|
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
|
||||||
|
.catch(() => setHasWechat(null)),
|
||||||
|
]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openTime = String(store?.openTime || '09:30');
|
useEffect(() => {
|
||||||
const closeTime = String(store?.closeTime || '22:00');
|
void loadMine();
|
||||||
|
}, [loadMine]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
||||||
|
void handleShopWechatCallback()
|
||||||
|
.then((result) => {
|
||||||
|
if (!result) return;
|
||||||
|
const session = handleShopWechatLoginResult(result);
|
||||||
|
if (session) {
|
||||||
|
applySession(session);
|
||||||
|
setHasWechat(true);
|
||||||
|
setBindMsg('微信绑定成功');
|
||||||
|
}
|
||||||
|
stripOAuthParamsFromLocation();
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
})
|
||||||
|
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
|
||||||
|
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
|
||||||
|
|
||||||
|
const hoursParts: string[] = [];
|
||||||
|
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||||
|
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||||
|
const hoursText = hoursParts.length ? hoursParts.join(',') : '09:30 - 22:00';
|
||||||
const multiStore = (profile?.stores?.length ?? 0) > 1;
|
const multiStore = (profile?.stores?.length ?? 0) > 1;
|
||||||
|
const showBindWechat = wxAuthorize && hasWechat === false;
|
||||||
|
|
||||||
|
async function bindWechat() {
|
||||||
|
if (binding) return;
|
||||||
|
setBindMsg('');
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
setBindMsg('请在微信内打开门店端以绑定微信');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBinding(true);
|
||||||
|
try {
|
||||||
|
const result = await authorizeShopWechat();
|
||||||
|
if (!result) {
|
||||||
|
// 跳转公众号 OAuth,回跳后由上面 useEffect 处理
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = handleShopWechatLoginResult(result);
|
||||||
|
if (session) {
|
||||||
|
applySession(session);
|
||||||
|
setHasWechat(true);
|
||||||
|
setBindMsg('微信绑定成功');
|
||||||
|
} else {
|
||||||
|
setBindMsg('绑定未完成,请重试');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setBindMsg(e instanceof Error ? e.message : '微信绑定失败');
|
||||||
|
} finally {
|
||||||
|
setBinding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-mine-page">
|
<PullToRefresh onRefresh={loadMine} className="shop-mine-page">
|
||||||
<header className="shop-mine-header">
|
<header className="shop-mine-header">
|
||||||
<h1 className="app-page-title">我的</h1>
|
<h1 className="app-page-title">我的</h1>
|
||||||
</header>
|
</header>
|
||||||
@@ -51,7 +128,7 @@ export default function MinePage() {
|
|||||||
<div className="shop-mine-info-row">
|
<div className="shop-mine-info-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-mine-info-label">营业时间</p>
|
<p className="shop-mine-info-label">营业时间</p>
|
||||||
<p className="shop-mine-info-value">{openTime} - {closeTime}</p>
|
<p className="shop-mine-info-value">{hoursText}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +154,20 @@ export default function MinePage() {
|
|||||||
<p>如需修改信息请联系城市合伙人</p>
|
<p>如需修改信息请联系城市合伙人</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bindMsg ? <p className="shop-mine-bind-msg">{bindMsg}</p> : null}
|
||||||
|
|
||||||
|
{showBindWechat ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-mine-bind-wechat"
|
||||||
|
disabled={binding}
|
||||||
|
onClick={() => void bindWechat()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>chat</span>
|
||||||
|
{binding ? '绑定中…' : '绑定微信'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-mine-logout"
|
className="shop-mine-logout"
|
||||||
@@ -86,6 +177,6 @@ export default function MinePage() {
|
|||||||
退出登录
|
退出登录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
type BalanceResult = {
|
|
||||||
sessionId: string;
|
|
||||||
totalBalance: number;
|
|
||||||
maskedPhone: string;
|
|
||||||
user?: { nickname?: string; phone?: string; userNo?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step = 'lookup' | 'amount' | 'confirm';
|
|
||||||
|
|
||||||
export default function PhoneRedeemPage() {
|
export default function PhoneRedeemPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [step, setStep] = useState<Step>('lookup');
|
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [lookupCode, setLookupCode] = useState('');
|
|
||||||
const [confirmCode, setConfirmCode] = useState('');
|
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [balance, setBalance] = useState<BalanceResult | null>(null);
|
const [confirmCode, setConfirmCode] = useState('');
|
||||||
|
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
const [storeClosed, setStoreClosed] = useState(false);
|
const [storeClosed, setStoreClosed] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [lookupCooldown, setLookupCooldown] = useState(0);
|
|
||||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
|
|||||||
.catch(() => setStoreName('当前门店'));
|
.catch(() => setStoreName('当前门店'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (lookupCooldown <= 0) return;
|
|
||||||
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [lookupCooldown]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (confirmCooldown <= 0) return;
|
if (confirmCooldown <= 0) return;
|
||||||
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [confirmCooldown]);
|
}, [confirmCooldown]);
|
||||||
|
|
||||||
async function sendLookupSms() {
|
async function sendConfirmSms() {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||||
setMsg('请输入正确的手机号');
|
setMsg('请输入正确的手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
|
||||||
setMsg('');
|
|
||||||
try {
|
|
||||||
await request('SHOP_H5', '/shop/redeem/phone/send-lookup-sms', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: phone.trim() }),
|
|
||||||
});
|
|
||||||
setLookupCooldown(60);
|
|
||||||
setMsg('验证码已发送至用户手机');
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queryBalance() {
|
|
||||||
if (!lookupCode.trim()) {
|
|
||||||
setMsg('请输入验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setMsg('');
|
|
||||||
try {
|
|
||||||
const res = await request<BalanceResult>('SHOP_H5', '/shop/redeem/phone/balance', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: phone.trim(), code: lookupCode.trim() }),
|
|
||||||
});
|
|
||||||
setBalance(res);
|
|
||||||
setStep('amount');
|
|
||||||
setMsg('');
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(e instanceof Error ? e.message : '查询失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareRedeem() {
|
|
||||||
if (storeClosed) {
|
if (storeClosed) {
|
||||||
setMsg('门店未营业,无法核销');
|
setMsg('门店未营业,无法核销');
|
||||||
return;
|
return;
|
||||||
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
|
|||||||
setMsg('请输入有效核销金额');
|
setMsg('请输入有效核销金额');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (balance && value > balance.totalBalance) {
|
|
||||||
setMsg('核销金额不能超过可用权益');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
await request('SHOP_H5', '/shop/redeem/phone/prepare', {
|
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
|
body: JSON.stringify({ phone: phone.trim(), amount: value }),
|
||||||
});
|
});
|
||||||
|
setPrepared(result);
|
||||||
|
setConfirmCode('');
|
||||||
setConfirmCooldown(60);
|
setConfirmCooldown(60);
|
||||||
setStep('confirm');
|
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||||
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '发起核销失败');
|
setPrepared(null);
|
||||||
|
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmRedeem() {
|
async function confirmRedeem() {
|
||||||
|
if (!prepared) {
|
||||||
|
setMsg('请先发送核销验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!confirmCode.trim()) {
|
if (!confirmCode.trim()) {
|
||||||
setMsg('请输入确认验证码');
|
setMsg('请输入确认验证码');
|
||||||
return;
|
return;
|
||||||
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
|
|||||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sessionId: balance?.sessionId,
|
sessionId: prepared.sessionId,
|
||||||
code: confirmCode.trim(),
|
code: confirmCode.trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||||
navigate('/redeem/success', {
|
navigate('/redeem/success', {
|
||||||
state: { result, storeName, user: balance?.user },
|
state: { result, storeName, user: prepared.user },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||||
@@ -151,7 +97,9 @@ export default function PhoneRedeemPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
|
const amountValue = Number(amount);
|
||||||
|
const canSendCode =
|
||||||
|
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-redeem-page">
|
<div className="shop-redeem-page">
|
||||||
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shop-redeem-body">
|
<div className="shop-redeem-body">
|
||||||
{step === 'lookup' && (
|
<div className="shop-phone-field">
|
||||||
<>
|
<label className="shop-phone-label">用户手机号</label>
|
||||||
<div className="shop-phone-field">
|
<input
|
||||||
<label className="shop-phone-label">用户手机号</label>
|
className="shop-phone-input"
|
||||||
<input
|
type="tel"
|
||||||
className="shop-phone-input"
|
maxLength={11}
|
||||||
type="tel"
|
placeholder="请输入用户手机号"
|
||||||
maxLength={11}
|
value={phone}
|
||||||
placeholder="请输入用户手机号"
|
disabled={loading}
|
||||||
value={phone}
|
onChange={(e) => {
|
||||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
setPhone(e.target.value.replace(/\D/g, ''));
|
||||||
/>
|
setPrepared(null);
|
||||||
</div>
|
setConfirmCode('');
|
||||||
<div className="shop-phone-field">
|
setConfirmCooldown(0);
|
||||||
<label className="shop-phone-label">验证码</label>
|
}}
|
||||||
<div className="shop-phone-code-row">
|
/>
|
||||||
<input
|
</div>
|
||||||
className="shop-phone-input"
|
|
||||||
type="text"
|
|
||||||
maxLength={6}
|
|
||||||
placeholder="用户收到的验证码"
|
|
||||||
value={lookupCode}
|
|
||||||
onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-phone-code-btn"
|
|
||||||
disabled={loading || lookupCooldown > 0 || !phone.trim()}
|
|
||||||
onClick={() => void sendLookupSms()}
|
|
||||||
>
|
|
||||||
{lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-redeem-confirm-btn"
|
|
||||||
disabled={loading || storeClosed}
|
|
||||||
onClick={() => void queryBalance()}
|
|
||||||
>
|
|
||||||
查询权益
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'amount' && balance && (
|
<div className="shop-phone-field">
|
||||||
<>
|
<label className="shop-phone-label">待核销金额</label>
|
||||||
<div className="shop-redeem-user">
|
<input
|
||||||
<div className="shop-redeem-user-left">
|
className="shop-phone-input"
|
||||||
<span className="material-symbols-outlined">person</span>
|
type="number"
|
||||||
<span>用户</span>
|
min={0.01}
|
||||||
</div>
|
step={0.01}
|
||||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
placeholder="请输入待核销金额"
|
||||||
{userLabel}
|
value={amount}
|
||||||
</span>
|
disabled={loading}
|
||||||
</div>
|
onChange={(e) => {
|
||||||
<div className="shop-redeem-amount-section">
|
setAmount(e.target.value);
|
||||||
<p className="shop-redeem-amount-label">可用好客权益</p>
|
setPrepared(null);
|
||||||
<div className="shop-redeem-amount">
|
setConfirmCode('');
|
||||||
<span className="shop-redeem-amount-symbol">¥</span>
|
setConfirmCooldown(0);
|
||||||
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span>
|
}}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="shop-phone-field">
|
|
||||||
<label className="shop-phone-label">核销金额</label>
|
|
||||||
<input
|
|
||||||
className="shop-phone-input"
|
|
||||||
type="number"
|
|
||||||
min={0.01}
|
|
||||||
step={0.01}
|
|
||||||
placeholder="请输入核销金额"
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-redeem-confirm-btn"
|
|
||||||
disabled={loading || storeClosed || balance.totalBalance <= 0}
|
|
||||||
onClick={() => void prepareRedeem()}
|
|
||||||
>
|
|
||||||
发送确认验证码并核销
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-phone-link-btn"
|
|
||||||
onClick={() => {
|
|
||||||
setStep('lookup');
|
|
||||||
setBalance(null);
|
|
||||||
setAmount('');
|
|
||||||
setLookupCode('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
更换手机号
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'confirm' && balance && (
|
<div className="shop-phone-field">
|
||||||
<>
|
<label className="shop-phone-label">核销验证码</label>
|
||||||
<div className="shop-redeem-details">
|
<div className="shop-phone-code-row">
|
||||||
<div className="shop-redeem-detail-row">
|
<input
|
||||||
<span>用户</span>
|
className="shop-phone-input"
|
||||||
<span>{userLabel}</span>
|
type="text"
|
||||||
</div>
|
inputMode="numeric"
|
||||||
<div className="shop-redeem-detail-row">
|
maxLength={6}
|
||||||
<span>核销金额</span>
|
placeholder="输入用户收到的验证码"
|
||||||
<span>¥{formatAmount(Number(amount))}</span>
|
value={confirmCode}
|
||||||
</div>
|
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||||
</div>
|
/>
|
||||||
<div className="shop-phone-field">
|
|
||||||
<label className="shop-phone-label">核销确认验证码</label>
|
|
||||||
<input
|
|
||||||
className="shop-phone-input"
|
|
||||||
type="text"
|
|
||||||
maxLength={6}
|
|
||||||
placeholder="用户手机收到的确认码"
|
|
||||||
value={confirmCode}
|
|
||||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
|
||||||
/>
|
|
||||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
|
||||||
{confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-redeem-confirm-btn"
|
className="shop-phone-code-btn"
|
||||||
disabled={loading || storeClosed}
|
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
|
||||||
onClick={() => void confirmRedeem()}
|
onClick={() => void sendConfirmSms()}
|
||||||
>
|
>
|
||||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
|
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||||
className="shop-phone-link-btn"
|
验证码将发送到用户手机号,验证成功后直接完成核销。
|
||||||
onClick={() => {
|
</p>
|
||||||
setStep('amount');
|
</div>
|
||||||
setConfirmCode('');
|
|
||||||
}}
|
<button
|
||||||
>
|
type="button"
|
||||||
返回修改金额
|
className="shop-redeem-confirm-btn"
|
||||||
</button>
|
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
|
||||||
</>
|
onClick={() => void confirmRedeem()}
|
||||||
)}
|
>
|
||||||
|
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||||
|
</button>
|
||||||
|
|
||||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
type RangeKey = 'today' | '7d' | '30d';
|
type RangeKey = 'today' | '7d' | '30d';
|
||||||
@@ -28,15 +29,21 @@ export default function RecordsPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
const loadRecords = useCallback(() => {
|
||||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
return Promise.all([
|
||||||
setRecords(d.list || []);
|
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
||||||
});
|
setRecords(d.list || []);
|
||||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
}),
|
||||||
.then((s) => setStoreName(String(s.name || '')))
|
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||||
.catch(() => {});
|
.then((s) => setStoreName(String(s.name || '')))
|
||||||
|
.catch(() => {}),
|
||||||
|
]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRecords();
|
||||||
|
}, [loadRecords]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return records.filter((r) => {
|
return records.filter((r) => {
|
||||||
if (!inRange(String(r.createdAt), range)) return false;
|
if (!inRange(String(r.createdAt), range)) return false;
|
||||||
@@ -55,7 +62,7 @@ export default function RecordsPage() {
|
|||||||
}, [filtered]);
|
}, [filtered]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-records-page">
|
<PullToRefresh onRefresh={loadRecords} className="shop-records-page">
|
||||||
<header className="shop-records-header">
|
<header className="shop-records-header">
|
||||||
<h1 className="app-page-title">核销记录</h1>
|
<h1 className="app-page-title">核销记录</h1>
|
||||||
</header>
|
</header>
|
||||||
@@ -176,6 +183,6 @@ export default function RecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import {
|
import {
|
||||||
needsStoreSelection,
|
needsStoreSelection,
|
||||||
@@ -19,15 +20,19 @@ export default function SelectStorePage() {
|
|||||||
const currentStoreId = store?.storeId || '';
|
const currentStoreId = store?.storeId || '';
|
||||||
const canGoBack = Boolean(currentStoreId);
|
const canGoBack = Boolean(currentStoreId);
|
||||||
|
|
||||||
|
const loadStores = useCallback(() => {
|
||||||
|
return request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
||||||
|
.then((list) => setStores(list))
|
||||||
|
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
void loadStores();
|
||||||
.then((list) => setStores(list))
|
}, [authenticated, navigate, loadStores]);
|
||||||
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
|
||||||
}, [authenticated, navigate]);
|
|
||||||
|
|
||||||
async function onSelect(storeId: string) {
|
async function onSelect(storeId: string) {
|
||||||
if (loadingId) return;
|
if (loadingId) return;
|
||||||
@@ -58,12 +63,13 @@ export default function SelectStorePage() {
|
|||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string) {
|
||||||
if (status === 'OPEN') return '营业中';
|
if (status === 'OPEN') return '营业中';
|
||||||
if (status === 'PAUSED') return '暂停营业';
|
if (status === 'PAUSED') return '临时闭店';
|
||||||
|
if (status === 'CLOSED') return '永久关闭';
|
||||||
return status || '门店';
|
return status || '门店';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-select-store-page">
|
<PullToRefresh onRefresh={loadStores} className="shop-select-store-page">
|
||||||
<header className="shop-subpage-header">
|
<header className="shop-subpage-header">
|
||||||
{canGoBack ? (
|
{canGoBack ? (
|
||||||
<button
|
<button
|
||||||
@@ -151,7 +157,7 @@ export default function SelectStorePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user