58 Commits

Author SHA1 Message Date
jacy 81a6e3674b Merge pull request 'Dev' (#9) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/9
2026-07-17 10:50:17 +08:00
jacy 76270b20bf Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev
CI / verify (pull_request) Has been cancelled
2026-07-17 10:43:19 +08:00
jacy c2914c37e5 fix(partner): grant store staff open/close and media permissions by default
CI / verify (pull_request) Has been cancelled
Default new sub-accounts to store:create+store:manage, backfill empty permissions on /partner/me, and treat legacy store staff as allowed to mutate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:43:15 +08:00
jacy 9f9b7cb2bd merge(dev_jacy): partner store manage and env photo dedupe 2026-07-17 10:36:31 +08:00
jacy de396442a4 feat(partner): let staff manage stores and fix env photo dupes
CI / verify (pull_request) Has been cancelled
Allow store:create/manage sub-accounts to edit, open/close, and re-upload media; dedupe ENV photos on write/read and replace via media API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:36:29 +08:00
jacy 02efe6fa14 merge(dev_jacy): fix login agreement visibility 2026-07-17 10:20:36 +08:00
jacy 14b867a3a5 fix(auth): show user agreement before login actions
CI / verify (pull_request) Has been cancelled
Quick login required agreement without a checkbox; move the consent row above CTAs across partner/shop/user/mini login pages so it stays visible on short screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:20:32 +08:00
jacy f750fea667 merge(dev_jacy): skip unauthorized partner home APIs 2026-07-17 10:13:45 +08:00
jacy 55e91158b3 fix(partner): skip unauthorized home APIs for sub-accounts
CI / verify (pull_request) Has been cancelled
Gate dashboard/stores/orders by permission and move dashboard auth to method level so leaderboard stays open without toasting 403s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:13:43 +08:00
jacy 19cb13d26c merge(dev_jacy): partner sub-account team leaderboard 2026-07-17 10:02:44 +08:00
jacy 04253b9120 feat(partner): let sub-accounts view team leaderboard
CI / verify (pull_request) Has been cancelled
Expose same-primary ranking on home/me for all staff including warehouse roles, and open the leaderboard API without business permission gates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:02:42 +08:00
jacy aaf6ee81d5 merge(dev_jacy): fix mini-user production API origin 2026-07-17 09:30:12 +08:00
jacy 972bdde650 fix(mini-user): restore production API origin for H5 builds
CI / verify (pull_request) Has been cancelled
Production builds were baking localhost:3000 after the local-dev default change, causing failed to fetch on product list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 09:30:09 +08:00
jacy c0d66cfe50 merge(dev_jacy): admin订单配送/仓库自动发货/跨城起购与mini-user联调 2026-07-17 09:08:20 +08:00
jacy 00b52b9aaa 用户端跨城订单界面优化
CI / verify (pull_request) Has been cancelled
2026-07-17 09:06:53 +08:00
jacy e9537ce052 后端仓库配送逻辑完善
CI / verify (pull_request) Has been cancelled
2026-07-17 08:57:19 +08:00
jacy c204fb0a92 仓库自动配送开关
CI / verify (pull_request) Has been cancelled
2026-07-17 08:50:18 +08:00
jacy c643b0b979 webadmin端订单修改 2026-07-17 08:45:33 +08:00
jacy ed44e88e93 miniuser服务器连接地址修改
CI / verify (pull_request) Has been cancelled
2026-07-17 08:34:37 +08:00
jacy 57172d54d5 合伙人页面功
CI / verify (pull_request) Has been cancelled
2026-07-17 08:20:31 +08:00
jacy 608e405468 城市合伙人去掉订单补发功能,酒的订单如果没有配置仓库应该是看不到的
CI / verify (pull_request) Has been cancelled
2026-07-17 08:04:53 +08:00
jacy b36f2ea63a 城市合伙人H5端结算审核 2026-07-17 07:56:43 +08:00
jacy 7fa2c6fbed 城市合伙人端页面优化
CI / verify (pull_request) Has been cancelled
2026-07-17 07:45:20 +08:00
jacy bde85a7b82 仓库管理配置 小飞侠配置
CI / verify (pull_request) Has been cancelled
2026-07-16 21:40:25 +08:00
jacy b4ec9387a0 merge(dev_jacy): 仓配可调配履约与三分支推单 2026-07-16 17:51:32 +08:00
jacy f6d97b4ee8 feat(trade): 仓配可调配履约与三分支推单
CI / verify (pull_request) Has been cancelled
同城有仓按仓库绑定承运商自动推单或自管填单,无仓/跨城走总部快递;新增仓配注册表、FulfillmentService 及三端运单追踪。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:51:20 +08:00
jacy f1dc23c54c 合伙人 H5 改造
CI / verify (pull_request) Has been cancelled
2026-07-16 15:04:56 +08:00
jacy 9e6dc8c85c merge(dev_jacy): partner UX, shop wechat bind, remove H5 title whitespace 2026-07-16 11:40:55 +08:00
jacy fd5427c7ea 页面头部的白色留白去掉
CI / verify (pull_request) Has been cancelled
2026-07-16 11:38:44 +08:00
jacy 4de570f5f4 合伙人端的子账号管理页面优化
CI / verify (pull_request) Has been cancelled
2026-07-16 11:24:48 +08:00
jacy 22845e5d5f 门店端增加绑定微信按钮
CI / verify (pull_request) Has been cancelled
2026-07-16 08:30:04 +08:00
jacy 8d7507e2d8 杜康发布 skills 2026-07-16 08:28:46 +08:00
jacy 48995aace4 merge(dev_jacy): hide H5 in-page titles vs WeChat chrome 2026-07-16 08:05:46 +08:00
jacy 5a26841d3a fix(h5): hide in-page nav titles that duplicate WeChat chrome
CI / verify (pull_request) Has been cancelled
Keep back buttons and header layout. mini-user only hides titles on H5; partner/shop hide .app-page-title and legal titles; shared PageHeader defaults showTitle=false.
2026-07-16 08:05:44 +08:00
jacy 92ad0e16cf merge(dev_jacy): fix order detail receiver address display 2026-07-15 21:36:47 +08:00
jacy 7b14f4a0cc fix(mini-user): show real receiver fields on order detail
CI / verify (pull_request) Has been cancelled
Order detail used non-existent addressSnapshot; API returns receiverName/phone/address. Render those and fix product qty from items/quantity.
2026-07-15 21:36:44 +08:00
jacy d69bfa359a merge(dev_jacy): fix account merge WeChat transfer and phone prompt 2026-07-15 21:25:45 +08:00
jacy d3d56150c5 fix(auth): transfer WeChat id on merge; stop false phone prompts
CI / verify (pull_request) Has been cancelled
Merge used to drop wxOpenId so OAuth created a new guest without phone every time. Now migrate WeChat identity, recover legacy merged openIds, return accountMerged and force page reload, and only soft-prompt when phone is truly unbound.
2026-07-15 21:25:34 +08:00
jacy 6a5ddcc70a merge(dev_jacy): fix phone bind session bounce and mini-user UX 2026-07-15 21:11:22 +08:00
jacy 5c55d3c385 fix(mini-user): persist bind-phone session and stop login bounce
CI / verify (pull_request) Has been cancelled
Phone bind discarded the new JWT after account merge, so the next page 401'd back to login. Also harden 401 race, location deny cache, unpaid repay CTA, and finishLoginNavigate.
2026-07-15 21:10:42 +08:00
jacy b30adfea8e merge(dev_jacy): fix H5 pay appid/openid and login input align 2026-07-15 20:55:13 +08:00
jacy 02aecbeaab fix(mini-user): H5 pay use USER_H5; center login phone input
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:55:09 +08:00
jacy 734a15c9ce merge(dev_jacy): mini-user address/share/input UX fixes 2026-07-15 20:48:36 +08:00
jacy 9a3ccabcc9 fix(mini-user): address textarea freeze, input align, wechat share invoke
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:48:33 +08:00
jacy fd62b5bdbe merge(dev_jacy): UX fixes for address, checkbox, scan resume, tabbar 2026-07-15 20:37:43 +08:00
jacy 966ddc9219 fix(mini-user): address URL, form align, agreement checkbox, shop scan resume
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:37:40 +08:00
jacy 52a809649c 用户端mini-user 多了一层tab 2026-07-15 20:31:09 +08:00
jacy e307550ead merge(dev_jacy): fix mini-user WeChat OAuth clientApp mismatch 2026-07-15 20:22:40 +08:00
jacy 372c29bc3a fix(mini-user): use USER_MINI for WeChat SDK so OAuth token persists
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:22:37 +08:00
jacy 9c45011bd4 merge(dev_jacy): fix WeChat login create type for deploy 2026-07-15 20:12:39 +08:00
jacy acc5b78471 fix(api): type WeChat-only user create for nest build
CI / verify (pull_request) Has been cancelled
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:12:18 +08:00
jacy d70f2d8f62 Merge pull request 'Dev jacy' (#8) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/8
2026-07-15 20:04:10 +08:00
jacy 5fe1ff1fc3 mock短信页面回显
CI / verify (pull_request) Has been cancelled
2026-07-15 20:03:06 +08:00
jacy 0f5f0f4c2b 用户端不要强制用户输入手机号 2026-07-15 19:57:06 +08:00
jacy f15d2057f2 登录页的登录按钮看不见 2026-07-15 19:51:57 +08:00
jacy 68aa7d3ce2 用户协议默认不勾选 2026-07-15 19:50:09 +08:00
jacy f6c77e60b6 merge(dev_jacy): fix mini-user H5 useDidShow blank tab crash 2026-07-15 19:34:00 +08:00
jacy 5a2e8dfcf8 fix(mini-user): dedupe Taro reactMeta so H5 useDidShow works
CI / verify (pull_request) Has been cancelled
Vite was bundling plugin-framework-react runtime twice, so tab pages crashed with useContext is not a function on enter/switch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 19:33:51 +08:00
167 changed files with 7819 additions and 1709 deletions
@@ -23,7 +23,7 @@
| Guard | 用途 |
|-------|------|
| JwtAuthGuard | 需登录 |
| PhoneVerifiedGuard | C 端需绑定手机(下单/支付) |
| PhoneVerifiedGuard | (已废弃强制)C 端手机号改为下单页可选绑定 |
| OptionalJwtAuthGuard | 可选登录(bootstrap |
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`
+89
View File
@@ -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` / 私钥打进仓库
- 用户只说「修好」未说发布时 → **只改代码,不发版**
@@ -7,7 +7,7 @@
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 |
| REQ-U-002 | 登录 | 无感登录;下单强制手机号;微信/短信;7 天免登 |
| REQ-U-002 | 登录 | 无感登录;下单提示绑定手机号(可选);微信/短信;7 天免登 |
| REQ-U-003 | 支付 | 仅微信支付 |
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
+2
View File
@@ -90,6 +90,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用)
@@ -115,6 +116,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令
+2
View File
@@ -18,6 +18,7 @@ import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage';
import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
@@ -74,6 +75,7 @@ export default function App() {
<Route path="/cities" element={<CitiesPage />} />
<Route path="/city-partners" element={<CityPartnersPage />} />
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
@@ -292,7 +292,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(row) => {
@@ -56,6 +56,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
],
},
{
+27 -1
View File
@@ -79,6 +79,11 @@ export type DashboardStats = {
partnersTotal: number;
redeemToday: number;
deliveriesTotal: number;
pendingPayouts?: number;
/** 合伙人已确认、待总部审核打款 */
pendingBills?: number;
pendingPartnerDraftBills?: number;
openTickets?: number;
ordersByStatus: Array<{ status: string; count: number }>;
};
@@ -116,15 +121,36 @@ export type AdminUserRow = {
orderCount: number;
};
export type AdminOrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
export type AdminOrderRow = {
id: string;
orderNo: string;
status: string;
deliveryType: string;
payAmount: number;
productName?: string;
productSpec?: string;
quantity?: number;
receiverName: string;
receiverPhone: string;
createdAt: string;
cityId?: string;
city?: { id: string; name: string; code: string };
fulfillmentWarehouseId?: string | null;
fulfillmentWarehouse?: { id: string; name: 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;
};
};
@@ -224,7 +224,7 @@ export default function CityPartnersPage() {
async function openAddSubAccount(parentId: string) {
await openPartner(parentId);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
@@ -482,7 +482,7 @@ export default function CityPartnersPage() {
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(sub) => openSubEdit(sub, detail.id)}
+151 -42
View File
@@ -6,10 +6,12 @@ import {
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
@@ -17,10 +19,13 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WAREHOUSE_FULFILLMENT_MODE_LABELS,
WAREHOUSE_MANAGER_LABELS,
WAREHOUSE_STATUS_LABELS,
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
type FulfillmentProviderDto,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
@@ -39,6 +44,13 @@ type Row = {
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: string;
fulfillmentMode?: string;
fulfillmentProviderId?: string | null;
fulfillmentProviderName?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
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 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() {
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
@@ -73,6 +158,13 @@ export default function CityWarehousesPage() {
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
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 res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -88,6 +180,9 @@ export default function CityWarehousesPage() {
useEffect(() => {
void loadCities();
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
.then(setProviderOptions)
.catch(() => {});
}, [loadCities]);
async function openEdit(row: Row) {
@@ -102,23 +197,29 @@ export default function CityWarehousesPage() {
managerType: row.managerType,
partnerAccountId: row.partnerAccountId,
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);
}
function onManagerTypeChange(
type: WarehouseManagerType,
form: typeof createForm | typeof editForm,
setType: (v: WarehouseManagerType) => void,
v: WarehouseManagerType,
form: typeof createForm,
setType: (t: WarehouseManagerType) => void,
) {
setType(type);
if (type !== WarehouseManagerType.PARTNER) {
setType(v);
if (v !== WarehouseManagerType.PARTNER) {
form.setFieldValue('partnerAccountId', undefined);
}
}
const columns: ColumnsType<Row> = [
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
{
title: '城市',
width: 100,
@@ -128,17 +229,17 @@ export default function CityWarehousesPage() {
</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: 'contactPhone', width: 120 },
{
title: '管仓类型',
dataIndex: 'managerType',
width: 100,
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
title: '管仓',
width: 90,
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
},
{
title: '管仓合伙人',
title: '合伙人',
dataIndex: 'partnerCompanyName',
width: 120,
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: '状态',
dataIndex: 'status',
@@ -190,21 +301,23 @@ export default function CityWarehousesPage() {
return (
<div>
<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
type="primary"
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
setCreateManagerType(WarehouseManagerType.HQ);
setCreateCityId(undefined);
setPartners([]);
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
createForm.setFieldsValue({
managerType: WarehouseManagerType.HQ,
status: WarehouseStatus.ACTIVE,
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
});
setCreateOpen(true);
}}
>
</Button>
</Space>
@@ -212,7 +325,7 @@ export default function CityWarehousesPage() {
type="info"
showIcon
style={{ marginBottom: 16 }}
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
/>
<Form
@@ -225,7 +338,7 @@ export default function CityWarehousesPage() {
}}
>
<Form.Item name="name" label="仓库名">
<Input allowClear placeholder="仓库名" />
<Input allowClear />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
@@ -233,47 +346,33 @@ export default function CityWarehousesPage() {
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="managerType" label="管仓类型">
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} />
<Form.Item name="managerType" label="管仓">
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
</Form.Item>
<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>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
@@ -282,7 +381,7 @@ export default function CityWarehousesPage() {
/>
<Modal
title="新仓库"
title="新仓库"
open={createOpen}
width={520}
onCancel={() => setCreateOpen(false)}
@@ -341,6 +440,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={createFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setCreateFulfillmentMode}
/>
</Form>
</Modal>
@@ -399,6 +503,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态">
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={editFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setEditFulfillmentMode}
/>
</Form>
</Modal>
</div>
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
} from 'antd';
@@ -164,6 +165,47 @@ export default function DashboardPage() {
</Card>
</Col>
</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]}>
<Col xs={24} lg={12}>
<Card title="已合并访客账号" loading={loading}>
@@ -0,0 +1,274 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
FULFILLMENT_PROVIDER_STATUS_LABELS,
FULFILLMENT_PROVIDER_TYPE_LABELS,
FulfillmentProviderStatus,
FulfillmentProviderType,
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 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',
});
setOpen(true);
}
function openEdit(row: FulfillmentProviderDto) {
setEditRow(row);
const xfx = row.xiaofeixiaConfig;
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 || '',
});
setOpen(true);
}
async function submit() {
const v = await form.validateFields();
const payload: Record<string, unknown> = {
name: v.name,
type: v.type,
status: v.status,
};
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: '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">
使 API
</Typography.Text>
</div>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="小飞侠配置已从「系统设置 → 同城配送」迁至本页。寄件地址以仓库联系人/地址为准。"
/>
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
<Modal
title={editRow ? '编辑承运商' : '注册承运商'}
open={open}
onCancel={() => setOpen(false)}
onOk={() => void submit()}
width={560}
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>
{showXfxFields && (
<>
<Typography.Title level={5} style={{ marginTop: 8 }}>
</Typography.Title>
<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>
);
}
+453 -89
View File
@@ -9,6 +9,7 @@ import {
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Table,
@@ -17,8 +18,14 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api';
import {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
ORDER_STATUS_COLORS,
ORDER_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
type ShipDefaults = {
provider: string;
@@ -33,6 +40,21 @@ type ShipDefaults = {
payMode: string;
};
type CityOption = { id: string; name: string; code: string };
type WarehouseOption = {
id: string;
name: string;
cityId?: string;
cityName?: string;
status?: string;
contactName: string;
contactPhone: string;
address: string;
lng?: number | null;
lat?: number | null;
};
type OrderDetail = AdminOrderRow & {
receiverAddress?: string;
receiverProvince?: string;
@@ -53,16 +75,60 @@ type OrderDetail = AdminOrderRow & {
benefitAmount?: number;
paidAt?: string | null;
payExpireAt?: string | null;
items?: Array<Record<string, unknown>>;
productImage?: string;
listUnitPrice?: number;
items?: AdminOrderItem[];
payment?: Record<string, unknown> | null;
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
benefitCoupons?: Array<Record<string, unknown>>;
city?: { name: string; code: string };
fulfillmentWarehouse?: {
id: string;
name: string;
contactName?: string;
contactPhone?: string;
address?: string;
lng?: number | null;
lat?: number | null;
} | null;
};
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
if (detail.items?.length) return detail.items;
if (!detail.productName) return [];
return [{
productName: detail.productName,
productSpec: detail.productSpec ?? '',
productImage: detail.productImage ?? '',
unitPrice: Number(detail.listUnitPrice ?? 0),
quantity: detail.quantity ?? 1,
}];
}
function canShip(row: { status: string; delivery?: { trackingNo?: string | null } | null }) {
return ['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(row.status) && !row.delivery?.trackingNo;
}
function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): ShipDefaults {
return {
provider: 'XFX',
providerLabel: '小飞侠',
fromName: wh.contactName || base?.fromName || '杜康仓库',
fromMobile: wh.contactPhone || base?.fromMobile || '13800000000',
fromAddress: wh.address || base?.fromAddress || '',
fromAddressDetail: wh.name || base?.fromAddressDetail || '',
fromLng: wh.lng != null ? Number(wh.lng) : (base?.fromLng ?? 113.665),
fromLat: wh.lat != null ? Number(wh.lat) : (base?.fromLat ?? 34.757),
weight: base?.weight ?? 2,
payMode: base?.payMode ?? '1',
};
}
export default function OrdersPage() {
const [form] = Form.useForm();
const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm();
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
@@ -74,6 +140,12 @@ export default function OrdersPage() {
const [batchDeleting, setBatchDeleting] = useState(false);
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
const [shipping, setShipping] = useState(false);
const [logisticsShipping, setLogisticsShipping] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const [shipModalOpen, setShipModalOpen] = useState(false);
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -87,6 +159,7 @@ export default function OrdersPage() {
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (values.orderNo) qs.set('orderNo', values.orderNo);
if (values.status) qs.set('status', values.status);
if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res);
@@ -101,40 +174,100 @@ export default function OrdersPage() {
useEffect(() => {
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? []))
.catch(() => {});
}, []);
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
shipForm.setFieldsValue({
warehouseId: warehouseId || undefined,
provider: defaults.provider,
weight: defaults.weight,
payMode: defaults.payMode,
fromName: defaults.fromName,
fromMobile: defaults.fromMobile,
fromAddress: defaults.fromAddress,
fromAddressDetail: defaults.fromAddressDetail,
fromLng: defaults.fromLng,
fromLat: defaults.fromLat,
});
}
async function loadWarehouses() {
const res = await request<Paginated<WarehouseOption>>(
`/admin/city-warehouses?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&status=ACTIVE`,
).catch(() => null);
const rows = (res?.items ?? []).filter((w) => !w.status || w.status === 'ACTIVE');
setWarehouses(rows);
return rows;
}
async function openDetail(id: string) {
const res = await request<OrderDetail>(`/admin/orders/${id}`);
setDetail(res);
setDrawerOpen(true);
const defaults = shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null);
}
async function openShipModal(id: string) {
const res = await request<OrderDetail>(`/admin/orders/${id}`);
setShipTarget(res);
setDetail(res);
logisticsForm.setFieldsValue({
logisticsCompany: '',
trackingNo: '',
manualQueryUrl: '',
});
const rows = await loadWarehouses();
const preferredWarehouseId = res.fulfillmentWarehouseId || res.fulfillmentWarehouse?.id;
const preferred =
rows.find((w) => w.id === preferredWarehouseId) ||
rows.find((w) => w.cityId === (res.city?.id || res.cityId)) ||
rows[0];
const defaults = preferred
? warehouseToDefaults(preferred, shipDefaults)
: (shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null));
if (defaults) {
setShipDefaults(defaults);
shipForm.setFieldsValue({
provider: defaults.provider,
weight: defaults.weight,
payMode: defaults.payMode,
fromName: defaults.fromName,
fromMobile: defaults.fromMobile,
fromAddress: defaults.fromAddress,
fromAddressDetail: defaults.fromAddressDetail,
fromLng: defaults.fromLng,
fromLat: defaults.fromLat,
});
applyShipDefaults(defaults, preferred?.id);
} else {
shipForm.setFieldsValue({ warehouseId: preferred?.id, provider: 'XFX' });
}
setShipMode(preferred ? 'WAREHOUSE' : 'EXPRESS');
setShipModalOpen(true);
}
async function onWarehouseChange(warehouseId: string) {
const wh = warehouses.find((w) => w.id === warehouseId);
if (!wh) return;
applyShipDefaults(warehouseToDefaults(wh, shipDefaults), warehouseId);
}
async function submitShip() {
if (!detail) return;
if (!shipTarget) return;
const values = await shipForm.validateFields();
setShipping(true);
try {
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/ship`, {
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/ship`, {
method: 'POST',
body: JSON.stringify(values),
body: JSON.stringify({
provider: values.provider || 'XFX',
warehouseId: values.warehouseId,
weight: values.weight,
payMode: values.payMode,
remark: values.remark,
fromName: values.fromName,
fromMobile: values.fromMobile,
fromAddress: values.fromAddress,
fromAddressDetail: values.fromAddressDetail,
fromLng: values.fromLng,
fromLat: values.fromLat,
}),
});
message.success('发货成功');
message.success('选仓发货成功');
setShipTarget(res);
setDetail(res);
setShipModalOpen(false);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '发货失败');
@@ -143,6 +276,37 @@ export default function OrdersPage() {
}
}
async function submitLogisticsShip() {
if (!shipTarget) return;
const values = await logisticsForm.validateFields(['logisticsCompany', 'trackingNo', 'manualQueryUrl']);
const logisticsCompany = String(values.logisticsCompany ?? '').trim();
const trackingNo = String(values.trackingNo ?? '').trim();
if (!logisticsCompany || !trackingNo) {
message.error('请填写快递公司与运单号');
return;
}
setLogisticsShipping(true);
try {
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/logistics-ship`, {
method: 'POST',
body: JSON.stringify({
logisticsCompany,
trackingNo,
manualQueryUrl: String(values.manualQueryUrl ?? '').trim() || undefined,
}),
});
message.success('快递单已录入');
setShipTarget(res);
setDetail(res);
setShipModalOpen(false);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '填单失败');
} finally {
setLogisticsShipping(false);
}
}
async function confirmBatchDelete() {
if (!selectedRowKeys.length) return;
setBatchDeleting(true);
@@ -168,6 +332,25 @@ export default function OrdersPage() {
const columns: ColumnsType<AdminOrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{
title: '城市',
width: 90,
render: (_, row) => row.city?.name || '—',
},
{
title: '商品',
width: 180,
ellipsis: true,
render: (_, row) => {
const name = row.productName || '—';
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
return (
<span title={row.productSpec ? `${name}${row.productSpec}` : name}>
{name}{qty}
</span>
);
},
},
{
title: '状态',
dataIndex: 'status',
@@ -204,11 +387,19 @@ export default function OrdersPage() {
},
{
title: '操作',
width: 80,
width: 140,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
<Space size={0}>
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
{canShip(row) && (
<Button type="link" size="small" onClick={() => void openShipModal(row.id)}>
</Button>
)}
</Space>
),
},
];
@@ -232,6 +423,16 @@ export default function OrdersPage() {
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="receiverPhone" label="收货手机">
<Input allowClear />
</Form.Item>
@@ -248,7 +449,7 @@ export default function OrdersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 1500 }}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
@@ -272,6 +473,11 @@ export default function OrdersPage() {
onClose={() => setDrawerOpen(false)}
extra={detail && (
<Space>
{canShip(detail) && (
<Button type="primary" onClick={() => void openShipModal(detail.id)}>
</Button>
)}
<Typography.Text type="secondary"></Typography.Text>
<Select
value={detail.status}
@@ -295,6 +501,13 @@ export default function OrdersPage() {
<>
<Descriptions column={1} bordered size="small" title="基本信息">
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
<Descriptions.Item label="开城城市">
{detail.city?.name || '—'}
{detail.city?.code ? `${detail.city.code}` : ''}
</Descriptions.Item>
<Descriptions.Item label="履约仓">
{detail.fulfillmentWarehouse?.name || '未分配'}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
{ORDER_STATUS_LABELS[detail.status] || detail.status}
@@ -307,6 +520,51 @@ export default function OrdersPage() {
<Descriptions.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}></Typography.Title>
<Table
size="small"
rowKey={(_, i) => String(i)}
pagination={false}
dataSource={orderProductRows(detail)}
locale={{ emptyText: '无商品信息' }}
columns={[
{
title: '商品',
dataIndex: 'productName',
render: (name: string, row) => (
<Space>
{row.productImage ? (
<img
src={row.productImage}
alt=""
style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 4 }}
/>
) : null}
<span>
{name || '—'}
{row.productSpec ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{row.productSpec}
</Typography.Text>
) : null}
</span>
</Space>
),
},
{
title: '单价',
dataIndex: 'unitPrice',
width: 90,
render: (v: number) => `¥${v}`,
},
{
title: '数量',
dataIndex: 'quantity',
width: 70,
},
]}
/>
<Descriptions column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
@@ -324,74 +582,22 @@ export default function OrdersPage() {
{detail.delivery && (
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
<Descriptions.Item label="快递公司">
{DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || detail.delivery.provider}
{detail.delivery.logisticsCompany ||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
detail.delivery.provider}
</Descriptions.Item>
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
{detail.delivery.manualQueryUrl && (
<Descriptions.Item label="查询链接">
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
</a>
</Descriptions.Item>
)}
</Descriptions>
)}
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
<div style={{ marginTop: 16 }}>
<Typography.Title level={5}></Typography.Title>
<Form form={shipForm} layout="vertical" size="small">
<Form.Item name="provider" label="快递公司" rules={[{ required: true }]}>
<Select
options={[{ value: 'XFX', label: '小飞侠' }]}
/>
</Form.Item>
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
<Select
options={[
{ value: '1', label: '寄付' },
{ value: '2', label: '到付' },
]}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="可选" />
</Form.Item>
<Collapse
ghost
items={[{
key: 'from',
label: '寄件信息(默认仓库,可修改)',
children: (
<>
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space>
<Form.Item name="fromLng" label="经度">
<InputNumber step={0.001} />
</Form.Item>
<Form.Item name="fromLat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
),
}]}
/>
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
</Button>
</Form>
</div>
)}
{detail.statusLogs && detail.statusLogs.length > 0 && (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}></Typography.Title>
@@ -412,6 +618,150 @@ export default function OrdersPage() {
)}
</Drawer>
<Modal
title={shipTarget ? `配送发货 · ${shipTarget.orderNo}` : '配送发货'}
open={shipModalOpen}
onCancel={() => setShipModalOpen(false)}
width={560}
destroyOnClose={false}
forceRender
footer={
<Space>
<Button onClick={() => setShipModalOpen(false)}></Button>
{shipMode === 'WAREHOUSE' ? (
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
</Button>
) : (
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
</Button>
)}
</Space>
}
>
{shipTarget && (
<>
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
<Descriptions.Item label="收货">
{shipTarget.receiverName} {shipTarget.receiverPhone}
</Descriptions.Item>
<Descriptions.Item label="商品">
{shipTarget.productName || '—'}
{shipTarget.quantity != null ? ` ×${shipTarget.quantity}` : ''}
</Descriptions.Item>
</Descriptions>
<Radio.Group
value={shipMode}
onChange={(e) => setShipMode(e.target.value as ShipMode)}
optionType="button"
buttonStyle="solid"
style={{ marginBottom: 16 }}
options={[
{ value: 'WAREHOUSE', label: '选仓配送' },
{ value: 'EXPRESS', label: '填写快递单号' },
]}
/>
<div style={{ display: shipMode === 'WAREHOUSE' ? 'block' : 'none' }}>
<Form form={shipForm} layout="vertical" size="small" preserve>
<Form.Item
name="warehouseId"
label="选择仓库"
rules={[{ required: true, message: '请选择仓库' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder={warehouses.length ? '选择仓库' : '暂无可用仓库'}
options={warehouses.map((w) => ({
value: w.id,
label: w.cityName ? `${w.cityName} · ${w.name}` : w.name,
}))}
onChange={(id) => void onWarehouseChange(id)}
/>
</Form.Item>
<Form.Item name="provider" label="承运商" rules={[{ required: true }]} initialValue="XFX">
<Select options={[{ value: 'XFX', label: '小飞侠' }]} />
</Form.Item>
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
<Select
options={[
{ value: '1', label: '寄付' },
{ value: '2', label: '到付' },
]}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="可选" />
</Form.Item>
<Collapse
ghost
items={[{
key: 'from',
label: '寄件信息(默认仓库,可修改)',
children: (
<>
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space>
<Form.Item name="fromLng" label="经度">
<InputNumber step={0.001} />
</Form.Item>
<Form.Item name="fromLat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
),
}]}
/>
</Form>
</div>
<div style={{ display: shipMode === 'EXPRESS' ? 'block' : 'none' }}>
<Form form={logisticsForm} layout="vertical" size="small" preserve>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 0 }}>
</Typography.Paragraph>
<Form.Item
name="logisticsCompany"
label="快递公司"
rules={[{ required: true, message: '请填写快递公司' }]}
>
<Input placeholder="如 顺丰速运、京东物流" />
</Form.Item>
<Form.Item
name="trackingNo"
label="运单号"
rules={[{ required: true, message: '请填写运单号' }]}
>
<Input placeholder="请输入运单号" />
</Form.Item>
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
<Input placeholder="https://..." />
</Form.Item>
</Form>
</div>
</>
)}
</Modal>
<Modal
title={`确认批量删除(${selectedOrders.length} 笔)`}
open={batchDeleteOpen}
@@ -437,13 +787,27 @@ export default function OrdersPage() {
dataSource={selectedOrders}
columns={[
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
{
title: '城市',
width: 80,
render: (_, row) => row.city?.name || '—',
},
{
title: '商品',
width: 160,
ellipsis: true,
render: (_, row) =>
row.productName
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
: '—',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
function openAddSub(parent: AccountTreeRow) {
setSubParent(parent);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
+93 -16
View File
@@ -4,6 +4,7 @@ import {
Card,
DatePicker,
Form,
Input,
Modal,
Select,
Space,
@@ -29,6 +30,7 @@ type Row = {
status: string;
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
};
@@ -36,15 +38,17 @@ type Row = {
type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
CONFIRMED: '已确认',
DRAFT: '待合伙人确认',
CONFIRMED: '待打款审核',
PAID: '已打款',
REJECTED: '已驳回',
};
const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default',
CONFIRMED: 'blue',
CONFIRMED: 'orange',
PAID: 'green',
REJECTED: 'red',
};
export default function PartnerBillsPage() {
@@ -66,6 +70,10 @@ export default function PartnerBillsPage() {
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
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);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -95,7 +103,7 @@ export default function PartnerBillsPage() {
async function confirmBill(id: string) {
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
message.success('已确认');
message.success('已确认,进入待打款审核');
reload();
}
@@ -104,10 +112,33 @@ export default function PartnerBillsPage() {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
message.success('已标记打款');
message.success('已通过并标记打款');
reload();
}
function openReject(row: Row) {
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() {
setExporting(true);
try {
@@ -163,24 +194,38 @@ export default function PartnerBillsPage() {
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
width: 120,
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: '操作',
width: 160,
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Space size={0} wrap>
{row.status === 'DRAFT' && (
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
</Button>
)}
{row.status === 'CONFIRMED' && (
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button>
<>
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
</>
)}
</Space>
),
@@ -194,7 +239,7 @@ export default function PartnerBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+30 × ×
T+30
</Typography.Text>
</Space>
@@ -242,7 +287,7 @@ export default function PartnerBillsPage() {
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
style={{ width: 140 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
@@ -280,7 +325,7 @@ export default function PartnerBillsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 1280 }}
pagination={{
current: page,
pageSize,
@@ -320,6 +365,38 @@ export default function PartnerBillsPage() {
</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) : ''}
<br />
H5
</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>
</Form>
</Modal>
</div>
);
}
@@ -276,6 +276,16 @@ export default function SystemSettingsPage() {
const data = await request<SystemConfigFormResponse>('/admin/system-config');
if (silent) {
// 仅刷新 Mock 验证码列表,避免轮询用服务端值覆盖未保存的表单(含 MOCK_SMS 开关)
setMeta((prev) =>
prev
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
: data,
);
return;
}
setMeta(data);
form.setFieldsValue(data.values);
@@ -304,6 +314,7 @@ export default function SystemSettingsPage() {
useEffect(() => {
// 以表单开关为准展示验证码;轮询只刷列表,不回写表单
if (!mockSmsEnabled) return;
const timer = window.setInterval(() => void load(true), 5000);
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
Tabs, Tag, Typography, message,
@@ -15,6 +16,8 @@ type XfxConfig = {
hasApiKey: boolean;
signType: string;
ready: boolean;
source?: string;
hint?: string;
};
type ApiResult = {
@@ -115,8 +118,9 @@ export default function XiaofeixiaTestPage() {
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
HQ APIcmd 100101~100301
{' '}<code>XIAOFEIXIA_MCH_ID</code><code>XIAOFEIXIA_API_KEY</code>
HQ APIcmd 100101~100301
<Link to="/fulfillment-providers"></Link>
使
</Typography.Paragraph>
{config && (
@@ -132,13 +136,17 @@ export default function XiaofeixiaTestPage() {
<Descriptions.Item label="状态">
{config.ready ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>}
</Descriptions.Item>
<Descriptions.Item label="来源" span={3}>
{config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'}
{config.hint ? ` · ${config.hint}` : ''}
</Descriptions.Item>
</Descriptions>
{!config.ready && (
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message="请在 server/dukang-api/.env 中配置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY 后重启 API"
message="请先在仓配管理中注册小飞侠并填写 API 地址、商户号与 API Key"
/>
)}
</Card>
+3
View File
@@ -1,6 +1,7 @@
import { Routes, Route } from 'react-router-dom';
import AuthGate from './components/AuthGate';
import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import PartnerAppRoutes from './PartnerAppRoutes';
export default function App() {
@@ -8,6 +9,8 @@ export default function App() {
<AuthGate>
<Routes>
<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 />} />
</Routes>
</AuthGate>
+22 -9
View File
@@ -1,6 +1,6 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { usePartnerSession } from './contexts/PartnerSessionContext';
import { isSubAccount } from './lib/partnerAccess';
import { getPartnerNavKind, isSubAccount } from './lib/partnerAccess';
import TabLayout from './layouts/TabLayout';
import SubAccountLayout from './layouts/SubAccountLayout';
import HomePage from './pages/HomePage';
@@ -13,7 +13,6 @@ import CenterPage from './pages/CenterPage';
import PartnerMePage from './pages/PartnerMePage';
import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage';
import StaffListPage from './pages/StaffListPage';
@@ -26,7 +25,6 @@ function PrimaryRoutes() {
<Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/stores" element={<StoreListPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/center" element={<CenterPage />} />
</Route>
<Route path="/stores/new" element={<StoreCreatePage />} />
@@ -35,10 +33,10 @@ function PrimaryRoutes() {
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/reshipments" element={<ReshipPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -46,15 +44,30 @@ function PrimaryRoutes() {
}
function SubAccountRoutes() {
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isWarehouse = navKind === 'warehouse_staff';
return (
<Routes>
<Route element={<SubAccountLayout />}>
<Route path="/stores" element={<StoreListPage />} />
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route element={<SubAccountLayout navKind={navKind} />}>
<Route path="/" element={<HomePage />} />
{isWarehouse ? (
<Route path="/orders" element={<OrderListPage tabRoot />} />
) : (
<Route path="/stores" element={<StoreListPage />} />
)}
<Route path="/me" element={<PartnerMePage />} />
</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>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
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 }) {
const { ready, authenticated, account } = usePartnerSession();
@@ -50,7 +50,13 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
staffRole: profile.staffRole,
permissions: profile.permissions,
primaryAccountId: profile.primaryAccountId,
primaryPhone: profile.primaryPhone,
primaryName: profile.primaryName,
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 type { PartnerNavKind } from '../lib/partnerAccess';
const TABS = [
{ to: '/stores', end: true, icon: 'store', label: '门店管理' },
{ to: '/stores/new', icon: 'add_business', label: '录入门店' },
{ to: '/me', icon: 'person', label: '我的' },
const STORE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/stores', icon: 'store', label: '门店管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] 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 (
<>
<Outlet />
<nav className="app-tabbar">
{TABS.map((tab) => (
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
+20 -1
View File
@@ -24,7 +24,20 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
export type PartnerSessionProfile = Pick<
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;
};
@@ -109,7 +122,13 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
staffRole: me.staffRole ?? undefined,
permissions: me.permissions,
primaryAccountId: me.primaryAccountId,
primaryPhone: me.primaryPhone,
primaryName: me.primaryName,
hasWechat: me.hasWechat,
wxNickname: me.wxNickname,
wxAvatarUrl: me.wxAvatarUrl,
managedWarehouseId: me.managedWarehouseId,
hasWarehouseAccess: me.hasWarehouseAccess,
};
}
+17
View File
@@ -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 ?? '');
}
+1
View File
@@ -5,6 +5,7 @@ export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'tota
return request<PartnerLeaderboardResponse>(
'PARTNER_H5',
`/partner/dashboard/leaderboard?period=${period}`,
{ silent: true },
);
}
+80 -5
View File
@@ -1,5 +1,7 @@
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
return account?.isPrimary !== false;
}
@@ -17,15 +19,88 @@ export function hasPartnerPermission(
return account.permissions?.includes(permission) ?? false;
}
export function partnerHomePath(account: PartnerMe | null | undefined): string {
return isSubAccount(account) ? '/stores/new?step=1' : '/';
export function hasAnyPartnerPermission(
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;
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}/`)),
);
}
+6 -7
View File
@@ -1,9 +1,5 @@
import {
PartnerStaffRole,
type CreatePartnerStaffRequest,
type PartnerStaffItem,
type UpdatePartnerStaffRequest,
} from '@dukang/shared-types';
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
import { request } from './api';
export function listPartnerStaff() {
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
name: body.name,
phone: body.phone,
smsCode: body.smsCode,
staffRole: PartnerStaffRole.INTERNAL,
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: body.permissions?.length
? body.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
}),
});
}
+4
View File
@@ -14,6 +14,8 @@ export type PartnerProfile = {
phone: string;
companyName?: string;
hasWechat?: boolean;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
isPrimary?: boolean;
};
@@ -82,6 +84,8 @@ export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessio
companyName: partner.companyName ? String(partner.companyName) : undefined,
isPrimary: partner.isPrimary !== false,
hasWechat: true,
wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined,
wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined,
}
: undefined,
};
+1
View File
@@ -6,6 +6,7 @@ import App from './App';
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
import { PartnerToastProvider } from './contexts/PartnerToastContext';
import './styles.css';
import './styles/legal.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+148 -49
View File
@@ -1,42 +1,96 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerBillDto } from '@dukang/shared-types';
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 'DRAFT': return '待确认';
case 'CONFIRMED': return '审核中';
case 'PAID': return '已结算';
case 'REJECTED': return '已驳回';
default: return status;
}
}
function canApplyPayment(status: string) {
return status === 'DRAFT' || status === 'REJECTED';
}
export default function BillsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
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(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
void loadBills();
}, [navigate]);
const billId = searchParams.get('id');
const actionable = useMemo(
() => bills.filter((b) => canApplyPayment(b.status)),
[bills],
);
const bill = billId
? bills.find((b) => String(b.id) === billId) ?? bills[0]
: bills[0];
? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
: actionable[0] ?? bills[0];
function confirmBill() {
if (!confirmed) return;
const status = String(bill?.status || '');
const showApplyForm = !!bill && canApplyPayment(status);
const isReviewing = status === 'CONFIRMED';
const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID';
async function confirmBill() {
if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return;
setSubmitting(true);
setTimeout(() => {
try {
await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, {
method: 'POST',
});
toastSuccess('申请已提交,请耐心等待总部打款审核');
setConfirmed(false);
await loadBills();
} catch (e) {
toastError(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
alert('申请已提交,请耐心等待打款审核。(preV1 演示)');
}, 1500);
}
}
return (
<div className="partner-bills-page">
<PageHeader title="待确认账单" onBack={() => navigate('/center/settlement')} />
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
<div className="partner-bill-stepper">
<div className="partner-stepper-inner">
<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%' : isReviewing || isRejected ? '75%' : '50%' }}
/>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
@@ -45,17 +99,28 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span>
</div>
<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 || isReviewing || 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>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm">3</div>
<span className="partner-step-label"></span>
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
{isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'}
</div>
<span className={`partner-step-label${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'}
</span>
</div>
</div>
</div>
{!bill && <div className="empty"></div>}
{loading && <div className="empty"></div>}
{!loading && !bill && <div className="empty"></div>}
{bill && (
<section className="partner-bill-card">
@@ -66,25 +131,46 @@ export default function BillsPage() {
<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>
</div>
<span className="partner-status-pill partner-status-pill--paused">{String(bill.status || '待确认')}</span>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
</span>
</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>
)}
{isReviewing && (
<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">
<p className="text-muted body-md" style={{ marginBottom: 8 }}></p>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<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 style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="partner-info-row">
<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 className="partner-info-row">
<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>
@@ -93,45 +179,58 @@ export default function BillsPage() {
{bills.length > 1 && (
<div style={{ padding: '0 20px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
{bills.slice(1).map((b) => (
<div key={String(b.id)} className="partner-store-card" style={{ margin: '0 0 12px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
{bills.map((b) => (
<button
key={String(b.id)}
type="button"
className="partner-store-card"
style={{ margin: '0 0 12px', width: '100%', textAlign: 'left', cursor: 'pointer' }}
onClick={() => navigate(`/center/bills?id=${b.id}`)}
>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{String(b.billNo)}</p>
<p className="label-md text-muted">{String(b.status)}</p>
<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>
</div>
</button>
))}
</div>
)}
<div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
{showApplyForm && (
<>
<div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
{isRejected
? '请根据驳回理由核对后重新确认并申请打款。'
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
</p>
</div>
{bill && (
<footer className="partner-bill-footer">
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span></span>
</label>
<button
type="button"
className="partner-btn-primary"
disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={confirmBill}
>
{submitting ? '正在提交...' : '确认并申请打款'}
{!submitting && <span className="material-symbols-outlined">payments</span>}
</button>
</footer>
<footer className="partner-bill-footer">
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span></span>
</label>
<button
type="button"
className="partner-btn-primary"
disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={() => void confirmBill()}
>
{submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'}
{!submitting && <span className="material-symbols-outlined">payments</span>}
</button>
</footer>
</>
)}
</div>
);
+265 -133
View File
@@ -1,157 +1,289 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { contactSupport } from '../lib/contact';
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() {
const navigate = useNavigate();
const { account, logout } = usePartnerSession();
const showPrimaryMenus = isPrimaryAccount(account);
const me = account as unknown as Record<string, unknown> | null;
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
type CenterPageProps = {
/** 子账号个人中心复用 */
variant?: 'primary' | 'sub';
roleLabel?: string;
};
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(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, [navigate]);
document.title = isPrimary ? '合伙人中心' : '个人中心';
}, [isPrimary]);
const pendingBills = bills.filter((b) => String(b.status).includes('PENDING') || String(b.status).includes('CONFIRM'));
useEffect(() => {
if (!isPrimary) return;
void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
.then(setBills)
.catch(() => setBills([]));
void listPartnerStaff()
.then((list) => setStaffCount(list.length))
.catch(() => setStaffCount(0));
}, [isPrimary]);
const finance = useMemo(() => {
const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED');
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 === 'DRAFT' || b.status === 'CONFIRMED' || 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 (
<div className="page partner-center-page">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<div className="page partner-center-page partner-home--flush-top">
<section className="partner-profile-card">
<div className="partner-profile-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(me?.name || '合伙人')}</h2>
<span className="partner-role-badge"></span>
<button
type="button"
className="partner-profile-avatar"
onClick={() => void handleAvatarAuth()}
disabled={authorizing}
aria-label={avatarUrl ? '更新微信头像' : '微信授权获取头像'}
>
{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 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="body-md">{String(me?.companyName || '郑州')}</span>
<span className="body-md line-2-clamp">{locationLabel}</span>
</div>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(me?.phone || '')}</p>
</div>
</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>
{!isPrimary && (
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
)}
</div>
</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}>
<span className="material-symbols-outlined">logout</span>
退
</button>
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}>
<div className="partner-center-footer">
<p className="label-md text-muted"> · </p>
</div>
</div>
+306 -235
View File
@@ -1,279 +1,350 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
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) {
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() {
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 [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]);
const [notifOpen, setNotifOpen] = useState(false);
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
useEffect(() => {
document.title = '工作台';
}, []);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
// 等 session 带上账号后再按权限发请求,避免无权限接口弹错
if (!account) return;
if (canOrders) {
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) {
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
.then(setDash)
.catch(() => setDash(null));
} else {
setDash(null);
}
if (canStores) {
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
.then((data) => setStores(Array.isArray(data) ? data : []))
.catch(() => setStores([]));
} else {
setStores([]);
}
// 主账号与全部子账号均可查看同团队贡献榜(后端不校验业务权限点)
fetchPartnerLeaderboard('month')
.then((data) => setLeaderboardPreview(data.list.slice(0, 2)))
.catch(() => setLeaderboardPreview([]));
}, [navigate]);
.then((data) => {
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
})
.catch(() => {
setLeaderboardEntries([]);
});
}, [navigate, account, canOrders, canDashboard, canStores]);
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 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 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 created = new Date(String(s.createdAt || ''));
const now = new Date();
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
}).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 (
<div className="page partner-home">
<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>
)}
<div className="page partner-home partner-home--flush-top">
<main className="partner-home-body">
<section className="partner-revenue-card">
<p className="partner-revenue-label">
(CNY)
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
</p>
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
<div className="partner-revenue-grid">
<div>
<p className="partner-revenue-label"></p>
<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" />
{isPrimary && (
<>
<section className="partner-revenue-card">
<p className="partner-revenue-label">
(CNY)
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
</p>
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
<div className="partner-revenue-grid">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{activeStores}</p>
<p className="partner-revenue-label"></p>
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div>
</div>
<div className="partner-store-stat">
<span className="partner-dot partner-dot--red" />
<div>
<p className="label-md text-muted">/</p>
<p className="headline-md">{abnormalStores}</p>
</div>
</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>
</section>
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<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-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}>
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
</span>
<span className="partner-quick-action-label"></span>
</Link>
);
})
)}
</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>
<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>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
<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>
</>
)}
{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 className="partner-store-stat">
<span className="partner-dot partner-dot--red" />
<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)' }}>
{String(dash?.companyName || '郑州合伙人')} ·
</p>
</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 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>
</div>
);
@@ -47,7 +47,7 @@ export default function LeaderboardPage() {
return (
<div className="page-no-tab">
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
<div className="partner-leaderboard-tabs">
{PERIOD_TABS.map((tab) => (
+36
View File
@@ -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>
);
}
+43 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
@@ -24,6 +24,32 @@ import { isWechatEnv } from '../lib/weixin';
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account';
function AgreementCheckbox({
agreed,
onChange,
inputRef,
}: {
agreed: boolean;
onChange: (next: boolean) => void;
inputRef?: RefObject<HTMLLabelElement | null>;
}) {
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 maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
@@ -68,12 +94,13 @@ export default function LoginPage() {
const [phone, setPhone] = useState(remembered.phone || getLastPhone());
const [code, setCode] = useState('');
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(true);
const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
@@ -88,6 +115,7 @@ export default function LoginPage() {
function ensureAgreed() {
if (!agreed) {
setMsg('请先勾选并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
return true;
@@ -230,10 +258,16 @@ export default function LoginPage() {
<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>}
<AgreementCheckbox
agreed={agreed}
onChange={setAgreed}
inputRef={agreementRef}
/>
{canWechatQuick ? (
<button
type="button"
className="partner-btn-primary"
style={{ marginTop: 16 }}
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
@@ -241,7 +275,7 @@ export default function LoginPage() {
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="partner-auth-msg" style={{ textAlign: 'center', marginBottom: 12 }}>
<p className="partner-auth-msg" style={{ textAlign: 'center', marginTop: 12, marginBottom: 12 }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
@@ -321,6 +355,12 @@ export default function LoginPage() {
<span></span>
</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>}
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
@@ -339,13 +379,6 @@ export default function LoginPage() {
</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>
</main>
+63 -50
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
const TIMELINE = [
{ key: 'confirm', label: '待确认' },
@@ -28,21 +29,55 @@ function statusBanner(status: string) {
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() {
const { id } = useParams();
const navigate = useNavigate();
const [order, setOrder] = useState<Record<string, unknown> | null>(null);
const [shipping, setShipping] = useState(false);
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]);
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) {
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
method: 'POST',
body: JSON.stringify({ targetStatus: status }),
});
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
if (!id) return;
setShipping(true);
try {
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
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>;
@@ -50,6 +85,7 @@ export default function OrderDetailPage() {
const banner = statusBanner(String(order.status));
const currentIdx = statusIndex(String(order.status));
const payAmount = Number(order.payAmount || 0);
const showShip = canShip(String(order.status));
return (
<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)' }}>
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
</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 style={{ flex: 1 }}>
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
@@ -106,39 +139,6 @@ export default function OrderDetailPage() {
</div>
</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">
<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 }} />
@@ -156,21 +156,34 @@ export default function OrderDetailPage() {
</section>
<footer className="partner-order-footer">
<div>
<span className="label-md text-muted"></span>
<p className="headline-md text-primary" style={{ fontWeight: 700 }}>¥{(payAmount * 0.08).toFixed(2)}</p>
<div className="partner-order-footer-actions">
{showShip && (
<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>
<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>
{import.meta.env.DEV && (
<details className="partner-dev-tools">
<summary>Dev: Mock </summary>
{['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>
)}
+173 -42
View File
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { hasWarehouseAccess } from '../lib/partnerAccess';
type DateFilter = 'today' | '7d' | '30d';
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
@@ -29,17 +31,52 @@ const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ 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 [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 [dateFilter, setDateFilter] = useState<DateFilter>('today');
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(() => {
document.title = '订单管理';
}, []);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request('PARTNER_H5', '/partner/orders').then(setData);
}, [navigate]);
if (!warehouseOk) {
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
setLoaded(true);
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]);
const filtered = useMemo(() => data.list.filter((o) => {
if (statusFilter === 'ALL') return true;
@@ -51,13 +88,68 @@ export default function OrderListPage() {
return true;
}), [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 (
<div className="page-no-tab partner-orders-page">
<PageHeader title="订单中心" onBack={() => navigate('/')} />
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-segment">
<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>
{tab === 'orders' && (
@@ -82,50 +174,45 @@ export default function OrderListPage() {
{filtered.map((o) => {
const st = orderStatusLabel(String(o.status));
const payAmount = Number(o.payAmount || 0);
const orderId = String(o.id);
return (
<Link key={String(o.id)} to={`/orders/${o.id}`} className="partner-order-card">
<div className="partner-order-card-top">
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
</div>
<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 key={orderId} className="partner-order-card-wrap">
<Link to={`/orders/${orderId}`} className="partner-order-card">
<div className="partner-order-card-top">
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
<span className="partner-benefit-tag">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>confirmation_number</span>
¥{Math.round(payAmount * 0.2)}
</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 style={{ flex: 1, minWidth: 0 }}>
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
</div>
</div>
</div>
<div className="partner-order-address">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<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 className="partner-order-address">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥0.00</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="headline-md" style={{ marginTop: 4, color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</p>
</div>
</div>
</Link>
</Link>
{canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
<button
type="button"
className="partner-order-ship-btn"
disabled={shippingId === orderId}
onClick={(e) => void handleShip(orderId, e)}
>
</button>
)}
</div>
);
})}
</>
)}
{tab === 'coupons' && (
{tab === 'coupons' && !tabRoot && (
<div style={{ padding: '0 20px' }}>
<div className="partner-revenue-card" style={{ marginBottom: 16 }}>
<p className="partner-revenue-label"></p>
@@ -134,6 +221,50 @@ export default function OrderListPage() {
<p className="text-muted body-md text-center"> preV1 </p>
</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>
)}
</div>
);
}
+7 -97
View File
@@ -1,102 +1,12 @@
import { useEffect, useState } from 'react';
import { PARTNER_STAFF_ROLE_LABELS, type PartnerStaffRole } from '@dukang/shared-types';
import { request } from '../lib/api';
import { getPartnerNavKind } from '../lib/partnerAccess';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { toastError, toastSuccess } from '../lib/toast';
import CenterPage from './CenterPage';
/** 子账号个人中心:复用精简版 Center 布局 */
export default function PartnerMePage() {
const { account, refresh, logout } = usePartnerSession();
const [name, setName] = useState(account?.name ?? '');
const [saving, setSaving] = useState(false);
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isWarehouse = navKind === 'warehouse_staff';
useEffect(() => {
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>
);
return <CenterPage variant="sub" roleLabel={isWarehouse ? '仓库管理员' : '拓店员'} />;
}
+13 -4
View File
@@ -4,7 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -15,6 +15,7 @@ function billStatusLabel(status: string) {
case 'DRAFT': return '待结算';
case 'CONFIRMED': return '审核中';
case 'PAID': return '已结算';
case 'REJECTED': return '已驳回';
default: return status;
}
}
@@ -24,6 +25,7 @@ function billStatusClass(status: string) {
case 'DRAFT': return 'partner-settlement-status--pending';
case 'CONFIRMED': return 'partner-settlement-status--reviewing';
case 'PAID': return 'partner-settlement-status--settled';
case 'REJECTED': return 'partner-settlement-status--rejected';
default: return '';
}
}
@@ -70,9 +72,10 @@ export default function SettlementPage() {
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
if (!matchMonth) return false;
if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'DRAFT';
if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED';
if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true;
});
}, [bills, month, statusFilter]);
@@ -93,8 +96,9 @@ export default function SettlementPage() {
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待结算' },
{ key: 'settled', label: '已结算' },
{ key: 'reviewing', label: '审核中' },
{ key: 'rejected', label: '已驳回' },
{ key: 'settled', label: '已结算' },
];
return (
@@ -188,7 +192,12 @@ export default function SettlementPage() {
<span className="partner-settlement-dot" />
¥{fmtMoney(Number(bill.orderCommission || 0))}
</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 === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))}
</p>
</div>
@@ -20,7 +20,6 @@ import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import {
@@ -82,8 +81,6 @@ export default function StoreCreatePage() {
const { account, refresh } = usePartnerSession();
const subAccountLayout = isSubAccount(account);
const accountId = account?.id;
const wechatReady = !!account?.hasWechat;
@@ -526,7 +523,9 @@ export default function StoreCreatePage() {
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', {
@@ -610,7 +609,7 @@ export default function StoreCreatePage() {
return (
<div className={`partner-page-sticky${subAccountLayout ? ' partner-page-sticky--tabbar' : ''}`}>
<div className="partner-page-sticky">
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
@@ -1094,7 +1093,7 @@ export default function StoreCreatePage() {
<footer className={`partner-sticky-footer${subAccountLayout ? ' partner-sticky-footer--above-tabbar' : ''}`}>
<footer className="partner-sticky-footer">
{step > 1 && (
+139 -20
View File
@@ -5,7 +5,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField';
import {
canPartnerOpenStore,
storeAuditLabel,
@@ -16,19 +18,36 @@ import {
} from '../lib/storeStatus';
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() {
const { id } = useParams();
const navigate = useNavigate();
const { account } = usePartnerSession();
const subReadonly = isSubAccount(account);
const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
const [coverUrl, setCoverUrl] = useState('');
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [actionError, setActionError] = useState('');
const [wechatReady, setWechatReady] = useState(false);
function applyStore(data: Record<string, unknown>) {
setStore(data);
@@ -39,6 +58,15 @@ export default function StoreDetailPage() {
intro: String(data.intro || ''),
});
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(() => {
@@ -113,6 +141,42 @@ 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) {
return (
<div className="partner-detail-page">
@@ -124,13 +188,17 @@ export default function StoreDetailPage() {
if (!store) return <div className="empty">...</div>;
const envPhotos = Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
: [];
const envPhotos = uniqueEnvUrls(
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 auditPending = auditStatus === 'PENDING';
const auditRejected = auditStatus === 'REJECTED';
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
const canOpen = canPartnerOpenStore(auditStatus);
return (
@@ -170,7 +238,7 @@ export default function StoreDetailPage() {
)}
</section>
{!subReadonly && (
{canMutate && (
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
@@ -200,13 +268,29 @@ export default function StoreDetailPage() {
<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>
<div className="partner-cover">
<AppImage
src={store.coverUrl ? String(store.coverUrl) : null}
alt={form.name}
wrapperClassName="app-image--fill"
/>
</div>
{!canMutate || readOnly ? (
<div className="partner-cover">
<AppImage
src={coverUrl || null}
alt={form.name}
wrapperClassName="app-image--fill"
/>
</div>
) : (
<div style={{ marginBottom: 16 }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<OssUploadField
wide
bizType="STORE_TITLE"
mediaType="IMAGE"
value={coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={setCoverUrl}
label="点击更换门头照"
/>
</div>
)}
<div className="partner-field">
<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 }} />
@@ -234,13 +318,48 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<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>
<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>
{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}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
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">
{envPhotos.map((photo, index) => (
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
{envPhotos.map((url, index) => (
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
</div>
))}
</div>
@@ -264,7 +383,7 @@ export default function StoreDetailPage() {
<footer className="partner-save-footer">
<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}>
<span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
+15 -15
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
import {
canPartnerOpenStore,
storeAuditLabel,
@@ -25,11 +25,14 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
export default function StoreListPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession();
const readonly = isSubAccount(account);
const canMutate = canManagePartnerStore(account);
const canCreate = canCreatePartnerStore(account);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
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 [error, setError] = useState('');
@@ -42,6 +45,10 @@ export default function StoreListPage() {
void loadStores();
}, [navigate, loadStores]);
useEffect(() => {
document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
@@ -78,16 +85,7 @@ export default function StoreListPage() {
}
return (
<div className="page partner-store-page">
<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>
<div className="page partner-store-page partner-home--flush-top">
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
<div className="partner-sticky-filter">
@@ -104,12 +102,14 @@ export default function StoreListPage() {
</div>
</div>
{canCreate && (
<Link to="/stores/new" className="partner-fab-link">
<button type="button" className="partner-btn-primary">
<span className="material-symbols-outlined">add_business</span>
</button>
</Link>
)}
{filtered.length === 0 && <div className="empty"></div>}
@@ -149,7 +149,7 @@ export default function StoreListPage() {
</div>
</div>
</Link>
{!readonly && (
{canMutate && (
<div className="partner-store-card-actions">
<button
type="button"
+414 -34
View File
@@ -13,13 +13,23 @@ html {
/* ── Partner auth ── */
.partner-auth-page {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px var(--space-page) var(--space-page);
justify-content: flex-start;
padding: 48px var(--space-page) calc(24px + env(safe-area-inset-bottom, 0px));
background-color: var(--color-background);
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 {
@@ -170,10 +180,23 @@ html {
gap: 12px;
margin-top: var(--space-md);
font-size: 12px;
line-height: 1.5;
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 {
margin-top: var(--space-lg);
@@ -621,9 +644,6 @@ html {
.partner-expansion-split {
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 {
@@ -635,6 +655,28 @@ html {
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 {
margin: 0 20px 12px;
padding: 10px 12px;
@@ -845,6 +887,57 @@ html {
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 {
flex: 1;
min-width: 0;
@@ -1296,7 +1389,7 @@ nav.app-tabbar .app-tabbar-label {
.partner-sticky-footer--above-tabbar {
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
z-index: 90;
z-index: 110;
}
.partner-stepper {
@@ -1752,12 +1845,13 @@ nav.app-tabbar .app-tabbar-label {
max-width: 480px;
margin: 0 auto;
transform: none;
background: rgba(250, 249, 247, 0.92);
background: rgba(250, 249, 247, 0.96);
backdrop-filter: blur(8px);
border-top: 1px solid rgba(226, 190, 188, 0.2);
box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05);
padding: 16px var(--space-page) calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 60;
/* 高于 .app-tabbar(100),防止偶发叠层遮挡「下一步/提交」 */
z-index: 110;
display: flex;
align-items: center;
justify-content: center;
@@ -1801,7 +1895,7 @@ nav.app-tabbar .app-tabbar-label {
.partner-sticky-filter {
position: sticky;
top: 56px;
top: 0;
z-index: 40;
background: rgba(250, 249, 247, 0.95);
backdrop-filter: blur(8px);
@@ -2401,6 +2495,14 @@ nav.app-tabbar .app-tabbar-label {
justify-content: center;
position: relative;
flex-shrink: 0;
padding: 0;
cursor: pointer;
overflow: hidden;
}
.partner-profile-avatar:disabled {
opacity: 0.7;
cursor: wait;
}
.partner-profile-avatar .material-symbols-outlined {
@@ -2409,22 +2511,81 @@ nav.app-tabbar .app-tabbar-label {
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 {
flex-shrink: 0;
background: var(--color-secondary-container);
color: var(--color-on-secondary-container);
padding: 4px 8px;
border-radius: 8px;
font-family: var(--font-label);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
line-height: 1.2;
}
.partner-assets-section {
margin-top: var(--space-lg);
}
.partner-bills-banner {
margin: 0 var(--space-page) 12px;
padding: 16px;
background: rgba(166, 29, 36, 0.05);
border: 1px solid rgba(166, 29, 36, 0.1);
background: rgba(166, 29, 36, 0.06);
border: 1px solid rgba(166, 29, 36, 0.12);
border-radius: var(--radius-md);
display: flex;
align-items: center;
@@ -2463,13 +2624,100 @@ nav.app-tabbar .app-tabbar-label {
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
border: 1px solid rgba(166, 29, 36, 0.05);
text-decoration: none;
color: inherit;
display: block;
}
.partner-finance-card--hero {
grid-column: span 2;
background: var(--color-heritage-red);
grid-column: 1 / -1;
background: linear-gradient(135deg, #a61d24 0%, #820012 100%);
color: #fff;
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 {
@@ -2488,14 +2736,28 @@ nav.app-tabbar .app-tabbar-label {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 16px;
border: none;
border-bottom: 1px solid var(--color-surface-container);
border-radius: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
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:active {
background: rgba(166, 29, 36, 0.04);
}
.partner-menu-item-left {
display: flex;
align-items: center;
@@ -2513,24 +2775,6 @@ nav.app-tabbar .app-tabbar-label {
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 ── */
.partner-settlement-page {
min-height: 100vh;
@@ -2778,6 +3022,11 @@ nav.app-tabbar .app-tabbar-label {
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 {
display: flex;
align-items: center;
@@ -3065,3 +3314,134 @@ body {
padding-top: 12px;
border-top: 1px dashed rgba(166, 29, 36, 0.15);
}
.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 {
padding-top: 12px;
}
.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;
}
+78
View File
@@ -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
View File
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import AuthGate from './components/AuthGate';
import TabLayout from './layouts/TabLayout';
import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import SelectStorePage from './pages/SelectStorePage';
import HomePage from './pages/HomePage';
import RedeemConfirmPage from './pages/RedeemConfirmPage';
@@ -17,6 +18,8 @@ export default function App() {
<AuthGate>
<Routes>
<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="/staff" element={<StaffPage />} />
<Route path="/redeem" element={<RedeemConfirmPage />} />
+1 -1
View File
@@ -2,7 +2,7 @@ import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api';
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';
export default function AuthGate({ children }: { children: React.ReactNode }) {
+25 -2
View File
@@ -9,6 +9,8 @@ export type ShopAccountProfile = {
storeId: string;
name: string;
phone: string;
/** 是否已绑定微信(来自 /shop/auth/me account.hasWechat */
hasWechat?: boolean;
wxOpenId?: string | null;
store?: { name: string };
};
@@ -18,7 +20,27 @@ export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
}
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(
@@ -26,7 +48,8 @@ export function needsWechatAuth(
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
): boolean {
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> {
+1
View File
@@ -5,6 +5,7 @@ import { getRouterBasename } from '@dukang/weixin-sdk';
import { StoreSessionProvider } from './contexts/StoreSessionContext';
import App from './App';
import './styles.css';
import './styles/legal.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+34 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api';
@@ -43,13 +43,37 @@ export default function HomePage() {
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
useEffect(() => {
request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard').then((d) => {
setDash(d);
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
});
const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => {
setDash(d);
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
})
.catch(() => {});
}, []);
useEffect(() => {
void loadDashboard();
}, [loadDashboard]);
useEffect(() => {
function onResume() {
setScanning(false);
void loadDashboard();
}
function onVisibility() {
if (document.visibilityState === 'visible') onResume();
}
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pageshow', onResume);
window.addEventListener('focus', onResume);
return () => {
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pageshow', onResume);
window.removeEventListener('focus', onResume);
};
}, [loadDashboard]);
useEffect(() => {
if (!isWechatEnv() || !searchParams.get('code')) return;
void handleShopWechatCallback()
@@ -84,7 +108,10 @@ export default function HomePage() {
try {
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode();
if (!raw) return;
if (!raw) {
void loadDashboard();
return;
}
const token = parseRedeemTokenFromScan(raw);
if (!token) {
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
+36
View File
@@ -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>
);
}
+45 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
@@ -28,6 +28,36 @@ function formatWechatError(e: unknown): string {
return text;
}
function ShopAgreementCheckbox({
agreed,
onChange,
labelRef,
}: {
agreed: boolean;
onChange: (next: boolean) => void;
labelRef?: RefObject<HTMLLabelElement | null>;
}) {
return (
<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() {
const navigate = useNavigate();
const { applySession } = useStoreSession();
@@ -36,12 +66,13 @@ export default function LoginPage() {
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(true);
const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
@@ -71,6 +102,7 @@ export default function LoginPage() {
function ensureAgreed() {
if (!agreed) {
setMsg('请先阅读并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
return true;
@@ -180,6 +212,11 @@ export default function LoginPage() {
<div className="shop-quick-actions">
{msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
{canWechatQuick ? (
<button
type="button"
@@ -270,6 +307,12 @@ export default function LoginPage() {
{msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
<button
type="button"
className="shop-login-submit"
@@ -300,20 +343,6 @@ export default function LoginPage() {
</>
)}
</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>
<footer className="shop-login-footer">
+90 -2
View File
@@ -1,21 +1,95 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
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() {
const navigate = useNavigate();
const { resetSession, store: sessionStore } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
const { resetSession, store: sessionStore, applySession } = useStoreSession();
const profile = sessionStore ?? getStoreProfile();
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(() => {
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
}, []);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
useEffect(() => {
fetchShopAccount()
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
.catch(() => setHasWechat(null));
}, []);
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 openTime = String(store?.openTime || '09:30');
const closeTime = String(store?.closeTime || '22:00');
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 (
<div className="shop-mine-page">
@@ -77,6 +151,20 @@ export default function MinePage() {
<p></p>
</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
type="button"
className="shop-mine-logout"
+73 -21
View File
@@ -188,7 +188,7 @@
.shop-login-submit {
width: 100%;
margin-top: 32px;
margin-top: 16px;
padding: 16px;
border: none;
border-radius: var(--radius-md);
@@ -250,12 +250,16 @@
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 280px;
margin: 32px auto 0;
max-width: 100%;
margin: 16px 0 0;
padding: 8px 0;
}
.shop-login-agreement input {
margin-top: 4px;
margin-top: 2px;
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-heritage-red);
}
@@ -455,6 +459,15 @@
margin-top: auto;
}
.shop-quick-actions .shop-login-agreement {
margin: 0 0 16px;
max-width: 100%;
}
.shop-quick-actions .shop-quick-login-btn {
margin-top: 4px;
}
.shop-quick-login-btn {
width: 100%;
height: 56px;
@@ -1489,25 +1502,13 @@
}
.shop-records-header {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 480px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-page);
background: rgba(250, 249, 247, 0.9);
backdrop-filter: blur(10px);
z-index: 50;
/* 页内标题已隐藏:不再占位;保留类名以免改 JSX */
display: none !important;
}
.shop-records-filters {
position: sticky;
top: 56px;
top: 0;
z-index: 40;
background: var(--color-surface);
border-bottom: 1px solid var(--color-surface-container-highest);
@@ -1578,7 +1579,7 @@
}
.shop-records-main {
padding-top: 56px;
padding-top: 0;
}
.shop-records-summary {
@@ -2153,8 +2154,38 @@
color: var(--color-subtle-gray);
}
.shop-mine-bind-msg {
margin: 0 0 8px;
text-align: center;
font-size: 13px;
color: var(--color-heritage-red);
}
.shop-mine-bind-wechat {
margin-top: 8px;
width: 100%;
height: 48px;
border: none;
border-radius: var(--radius-md);
background: #07c160;
color: #fff;
font-family: var(--font-headline);
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
}
.shop-mine-bind-wechat:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.shop-mine-logout {
margin-top: 24px;
margin-top: 12px;
width: 100%;
height: 48px;
border: 1px solid var(--color-heritage-red);
@@ -2702,3 +2733,24 @@
cursor: pointer;
}
/* 微信 H5 系统标题已展示:隐藏页内重复标题,保留返回键与操作区 */
.app-page-title {
display: none !important;
}
/* 仅含标题的顶栏一并收起,去掉空白条;有返回/操作的顶栏保留 */
header:has(> .app-page-title:only-child),
.shop-home-header:has(> .app-page-title:only-child),
.shop-mine-header:has(> .app-page-title:only-child),
.shop-records-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;
}
+78
View File
@@ -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
View File
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { useEffect } from 'react';
import TabLayout from './layouts/TabLayout';
import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import HomePage from './pages/HomePage';
import ProductDetailPage from './pages/ProductDetailPage';
import OrderConfirmPage from './pages/OrderConfirmPage';
@@ -37,6 +38,8 @@ export default function App() {
<WechatShareBootstrap />
<Routes>
<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 element={<TabLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/stores" element={<StoreListPage />} />
@@ -105,8 +105,8 @@ export default function PhoneVerifySheet({
const sheetDesc =
description ??
(mode === 'wechat_bind_phone'
? '微信授权成功,请绑定手机号以完成支付'
: '下单前需验证手机号,以便接收订单通知');
? '建议绑定手机号,便于订单通知与售后;关闭可跳过继续支付'
: '建议绑定手机号,便于订单通知与售后;关闭可跳过继续下单');
return (
<div className="phone-verify-overlay" role="dialog" aria-modal="true">
@@ -159,6 +159,9 @@ export default function PhoneVerifySheet({
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
{loading ? '验证中...' : mode === 'wechat_bind_phone' ? '确认绑定' : '确认验证'}
</button>
<button type="button" className="phone-verify-skip" onClick={onClose}>
</button>
</div>
</div>
);
@@ -5,11 +5,10 @@ type SubPageHeaderProps = {
export default function SubPageHeader({ title, onBack }: SubPageHeaderProps) {
return (
<header className="sub-page-header">
<header className="sub-page-header" aria-label={title}>
<button type="button" className="sub-page-header-back" aria-label="返回" onClick={onBack}>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="app-page-title">{title}</h1>
</header>
);
}
@@ -7,9 +7,13 @@ type TabMainHeaderProps = {
};
export default function TabMainHeader({ title, extra, className = '' }: TabMainHeaderProps) {
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
if (!extra) {
return null;
}
return (
<header className={`tab-main-header${className ? ` ${className}` : ''}`}>
<h1 className="app-page-title">{title}</h1>
<header className={`tab-main-header${className ? ` ${className}` : ''}`} aria-label={title}>
{extra ? <div className="tab-main-header-extra">{extra}</div> : null}
</header>
);
+1
View File
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
import { getRouterBasename } from '@dukang/weixin-sdk';
import App from './App';
import './styles.css';
import './styles/legal.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+36
View File
@@ -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>
);
}
+28 -25
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import type { WechatLoginResult } from '@dukang/shared-types';
import { SmsScene } from '@dukang/shared-types';
@@ -26,11 +26,12 @@ export default function LoginPage() {
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [agreed, setAgreed] = useState(true);
const [agreed, setAgreed] = useState(false);
const [msg, setMsg] = useState('');
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [bindMode, setBindMode] = useState(false);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
useSmsCode();
@@ -51,12 +52,6 @@ export default function LoginPage() {
}, [wxAuthorize]);
function handleWechatLoginResult(result: WechatLoginResult) {
if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setMsg('微信授权成功,请绑定手机号完成登录');
return;
}
if (result.accessToken) {
applySession({
accessToken: result.accessToken,
@@ -68,11 +63,18 @@ export default function LoginPage() {
void finishLogin(navigate, returnTo);
return;
}
if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
return;
}
}
function ensureAgreed() {
if (!agreed) {
setMsg('请先勾选并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
return true;
@@ -194,6 +196,23 @@ export default function LoginPage() {
{displayMsg || sentHint}
</p>
)}
<label className="login-agreement" ref={agreementRef}>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(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>
<button
type="button"
className="login-sms-btn"
@@ -219,22 +238,6 @@ export default function LoginPage() {
</>
)}
</main>
<footer className="login-footer">
<label className="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>
</footer>
</div>
);
}
+48 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
@@ -73,6 +73,7 @@ export default function OrderConfirmPage() {
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
useEffect(() => {
request<Address[]>('USER_H5', '/user/addresses').then((list) => {
@@ -195,7 +196,7 @@ export default function OrderConfirmPage() {
setMsg('请选择收货地址');
return;
}
if (!phoneVerified) {
if (!phoneVerified && !phonePromptSkipped.current) {
setPendingSubmit(true);
setShowPhoneVerify(true);
return;
@@ -204,6 +205,7 @@ export default function OrderConfirmPage() {
const authResult = await ensureWechatAuthForPay();
if (!authResult.ok) {
if ('needBindPhone' in authResult) {
// 兼容旧接口:微信授权后提示可选绑定
setWxSessionKey(authResult.wxSessionKey);
setShowWechatBindPhone(true);
setPendingSubmit(true);
@@ -418,9 +420,35 @@ export default function OrderConfirmPage() {
<PhoneVerifySheet
open={showPhoneVerify}
defaultPhone={selectedAddress?.phone}
title="建议绑定手机号"
description="绑定后便于订单通知与售后联系;关闭即可跳过,不绑定也能继续下单。"
onClose={() => {
setShowPhoneVerify(false);
setPendingSubmit(false);
if (pendingSubmit) {
phonePromptSkipped.current = true;
setPendingSubmit(false);
void (async () => {
setLoading(true);
setMsg('');
try {
const authResult = await ensureWechatAuthForPay();
if (!authResult.ok) {
if ('needBindPhone' in authResult) {
setWxSessionKey(authResult.wxSessionKey);
setShowWechatBindPhone(true);
setPendingSubmit(true);
return;
}
return;
}
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
})();
}
}}
onSuccess={handlePhoneVerified}
/>
@@ -430,10 +458,26 @@ export default function OrderConfirmPage() {
mode="wechat_bind_phone"
wxSessionKey={wxSessionKey ?? undefined}
defaultPhone={selectedAddress?.phone}
title="建议绑定手机号"
description="绑定后便于订单通知与售后联系;关闭即可跳过继续下单。"
onClose={() => {
setShowWechatBindPhone(false);
setWxSessionKey(null);
setPendingSubmit(false);
if (pendingSubmit) {
phonePromptSkipped.current = true;
setPendingSubmit(false);
void (async () => {
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
})();
}
}}
onSuccess={handleWechatPhoneBound}
/>
+66 -3
View File
@@ -18,6 +18,15 @@ type OrderItem = {
type OrderDelivery = {
provider?: string;
trackingNo?: string;
logisticsCompany?: string;
manualQueryUrl?: string;
providerOrderNo?: string;
};
type TrackNode = {
trackInfo?: string;
createdAt?: string;
};
type OrderPayment = {
@@ -101,8 +110,10 @@ function progressActiveIndex(status: string) {
}
}
function deliveryProviderLabel(provider?: string) {
if (!provider || provider === 'MOCK' || provider === 'XIAOFEIXIA') return '小飞侠配送';
function deliveryProviderLabel(provider?: string, company?: string) {
if (company) return company;
if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') return '小飞侠配送';
if (provider === 'LOGISTICS') return '快递配送';
return provider;
}
@@ -124,13 +135,27 @@ export default function OrderDetailPage() {
const [copyHint, setCopyHint] = useState('');
const [shareToast, setShareToast] = useState('');
const [confirming, setConfirming] = useState(false);
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
const isReship = order?.orderType === 'RESHIPMENT';
async function loadTrack() {
if (!id) return;
try {
const data = await request<{ nodes?: TrackNode[] }>('USER_H5', `/trade/orders/${id}/track`);
setTrackNodes(data.nodes ?? []);
} catch {
setTrackNodes([]);
}
}
async function loadOrder() {
if (!id) return;
const data = await request<Order>('USER_H5', `/trade/orders/${id}`);
setOrder(data);
if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') {
void loadTrack();
}
}
useEffect(() => {
@@ -353,11 +378,49 @@ export default function OrderDetailPage() {
</div>
<div className="order-detail-kv">
<span></span>
<span>{deliveryProviderLabel(order.delivery?.provider)}</span>
<span>
{deliveryProviderLabel(order.delivery?.provider, order.delivery?.logisticsCompany)}
</span>
</div>
{order.delivery?.trackingNo && (
<div className="order-detail-kv">
<span></span>
<span>{order.delivery.trackingNo}</span>
</div>
)}
{order.delivery?.manualQueryUrl && (
<div className="order-detail-kv">
<span></span>
<a
href={order.delivery.manualQueryUrl}
target="_blank"
rel="noreferrer"
className="order-detail-link"
>
</a>
</div>
)}
</div>
</section>
{trackNodes.length > 0 && (
<section className="order-detail-card">
<h3 className="order-detail-card-title">
<span className="material-symbols-outlined">timeline</span>
</h3>
<div className="order-detail-kv-list order-detail-kv-list--bordered">
{trackNodes.map((node, index) => (
<div key={`${node.trackInfo}-${index}`} className="order-detail-kv">
<span>{node.createdAt ? formatDateTime(node.createdAt) : '—'}</span>
<span>{node.trackInfo || '—'}</span>
</div>
))}
</div>
</section>
)}
{!isReship && (
<section className="order-detail-card">
<h3 className="order-detail-card-title">
+38 -3
View File
@@ -917,6 +917,11 @@
z-index: 40;
}
/* 首页有城市栏顶栏时 top=56;无额外内容已不渲染顶栏时贴顶 */
.home-page:not(:has(.tab-main-header)) .home-aroma-nav {
top: 0;
}
.home-aroma-tab {
border: none;
background: none;
@@ -1555,6 +1560,7 @@
display: flex;
align-items: flex-start;
gap: 8px;
margin: 12px 0 16px;
font-family: var(--font-label);
font-size: 12px;
font-weight: 500;
@@ -1565,9 +1571,9 @@
}
.login-agreement input {
margin-top: 2px;
width: 16px;
height: 16px;
margin-top: 0;
width: 18px;
height: 18px;
flex-shrink: 0;
accent-color: var(--color-heritage-red);
}
@@ -5663,6 +5669,18 @@
line-height: 1.5;
}
.phone-verify-skip {
display: block;
width: 100%;
margin-top: 12px;
padding: 10px;
border: none;
background: transparent;
font-size: 14px;
color: var(--color-on-surface-variant, #888);
cursor: pointer;
}
.login-msg--hint {
color: var(--color-success, #2d6a4f);
}
@@ -5914,3 +5932,20 @@
opacity: 0.5;
}
/* 微信 H5 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */
.app-page-title {
display: none !important;
}
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;
}
+75
View File
@@ -0,0 +1,75 @@
.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;
font-size: 17px;
font-weight: 600;
}
.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;
}
+2
View File
@@ -5,6 +5,8 @@ export default defineAppConfig({
'pages/settlement/index',
'pages/tickets/index',
'pages/login/index',
'pages/user-agreement/index',
'pages/privacy-policy/index',
'pages/stores/detail',
'pages/orders/index',
'pages/orders/detail',
+5
View File
@@ -177,6 +177,11 @@
color: var(--hq-muted);
}
.login-agreement {
margin: 8px 0 12px;
padding: 4px 0;
}
.login-remember {
align-items: center;
padding: 0 4px;
+29 -13
View File
@@ -43,7 +43,7 @@ export default function LoginPage() {
const [phone, setPhone] = useState(remembered.phone || DEMO_PHONE);
const [code, setCode] = useState('123456');
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(true);
const [agreed, setAgreed] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
@@ -212,12 +212,38 @@ export default function LoginPage() {
<Text></Text>
</View>
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
<Text className="login-agreement-text">
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/user-agreement/index' });
}}
>
</Text>
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
}}
>
</Text>
</Text>
</View>
{msg ? <Text className="login-msg">{msg}</Text> : null}
<Button className="hq-btn hq-btn--primary hq-btn--block login-submit" loading={loading} onClick={smsLogin}>
</Button>
{msg ? <Text className="login-msg">{msg}</Text> : null}
<View className="login-divider">
<Text></Text>
</View>
@@ -229,16 +255,6 @@ export default function LoginPage() {
<WechatIcon />
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
</View>
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
<Text className="login-agreement-text">
<Text className="login-agreement-link"></Text>
<Text className="login-agreement-link"></Text>
</Text>
</View>
</View>
<View className="login-footer">
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '隐私政策',
});
@@ -0,0 +1,32 @@
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { getLegalDocument } from '@dukang/shared-types';
import '../user-agreement/legal.css';
export default function PrivacyPolicyPage() {
const doc = getLegalDocument('privacy-policy');
return (
<View className="hq-legal-page">
<View className="hq-legal-header">
<Text className="hq-legal-back" onClick={() => Taro.navigateBack()}>
</Text>
<Text className="hq-legal-title">{doc.title}</Text>
</View>
<ScrollView scrollY className="hq-legal-body">
<Text className="hq-legal-updated">{doc.updatedAt}</Text>
<Text className="hq-legal-intro">{doc.intro}</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="hq-legal-section">
<Text className="hq-legal-heading">{section.heading}</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="hq-legal-paragraph">
{p}
</Text>
))}
</View>
))}
</ScrollView>
</View>
);
}
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '用户协议',
});
@@ -0,0 +1,32 @@
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { getLegalDocument } from '@dukang/shared-types';
import './legal.css';
export default function UserAgreementPage() {
const doc = getLegalDocument('user-agreement');
return (
<View className="hq-legal-page">
<View className="hq-legal-header">
<Text className="hq-legal-back" onClick={() => Taro.navigateBack()}>
</Text>
<Text className="hq-legal-title">{doc.title}</Text>
</View>
<ScrollView scrollY className="hq-legal-body">
<Text className="hq-legal-updated">{doc.updatedAt}</Text>
<Text className="hq-legal-intro">{doc.intro}</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="hq-legal-section">
<Text className="hq-legal-heading">{section.heading}</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="hq-legal-paragraph">
{p}
</Text>
))}
</View>
))}
</ScrollView>
</View>
);
}
@@ -0,0 +1,68 @@
.hq-legal-page {
min-height: 100vh;
background: #faf9f7;
}
.hq-legal-header {
position: sticky;
top: 0;
z-index: 10;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: #faf9f7;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.hq-legal-back {
position: absolute;
left: 12px;
font-size: 28px;
line-height: 1;
padding: 4px 8px;
}
.hq-legal-title {
font-size: 17px;
font-weight: 600;
}
.hq-legal-body {
height: calc(100vh - 52px);
padding: 16px 20px 40px;
box-sizing: border-box;
}
.hq-legal-updated {
display: block;
font-size: 12px;
color: #8d706e;
margin-bottom: 12px;
}
.hq-legal-intro {
display: block;
font-size: 14px;
line-height: 1.7;
margin-bottom: 20px;
}
.hq-legal-section {
margin-bottom: 18px;
}
.hq-legal-heading {
display: block;
font-size: 15px;
font-weight: 600;
margin-bottom: 8px;
}
.hq-legal-paragraph {
display: block;
font-size: 13px;
line-height: 1.7;
color: #5c504c;
margin-bottom: 8px;
}
+13 -5
View File
@@ -18,7 +18,7 @@ pnpm --filter @dukang/mini-user dev:weapp
| 环节 | 文件 | 说明 |
|------|------|------|
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | 默认 `https://dkapi.runxian.top`;可用环境变量 `VITE_API_TARGET` 覆盖 |
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | 本地默认 `http://localhost:3000``NODE_ENV=production` 默认 `https://dkapi.runxian.top`;可用 `VITE_API_TARGET` 覆盖 |
| **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` |
### 微信登录 `invalid code`
@@ -30,17 +30,25 @@ pnpm --filter @dukang/mini-user dev:weapp
| 小程序 appid | `project.config.json``wxda31c8e8e85051e7` |
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
**本地联调(不接真实微信)**API 指到本机且开启 Mock
**本地联调(不接真实微信)**保持默认即可(API → `localhost:3000`),并开启 Mock
```bash
# 终端 1
pnpm dev:api # server/.env 保持 MOCK_WECHAT=true
pnpm dev:api # server/.env 保持 MOCK_SMS=true / MOCK_WECHAT=true
# 终端 2
$env:VITE_API_TARGET="http://localhost:3000"; pnpm dev:mini-user:weapp
pnpm dev:mini-user # H5 预览 :5177
# 或
pnpm --filter @dukang/mini-user dev:weapp
```
**连远程 API**`dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret,并部署含 `code2Session` 小程序凭证逻辑的后端。
**连远程 API**
```bash
$env:VITE_API_TARGET="https://dkapi.runxian.top"; pnpm --filter @dukang/mini-user dev
```
并在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
## 页面结构(18 页)
+39 -4
View File
@@ -1,8 +1,28 @@
import { createRequire } from 'node:module';
import path from 'node:path';
import { defineConfig } from '@tarojs/cli';
/** 小程序/H5 请求的后端 origin(不含 /api);本地联调可用 VITE_API_TARGET 覆盖 */
const API_ORIGIN = process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top';
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
const API_ORIGIN =
process.env.VITE_API_TARGET ??
(process.env.NODE_ENV === 'production' ? 'https://dkapi.runxian.top' : 'http://localhost:3000');
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
/** 解析包目录/文件,避免 pnpm/Vite 把同一 runtime 打成两份 → useDidShow 读到空 reactMeta */
function resolvePkgFile(id: string): string {
return requireFromApp.resolve(id);
}
function resolvePkgDir(id: string): string {
return path.dirname(requireFromApp.resolve(`${id}/package.json`));
}
const TARO_FRAMEWORK_RUNTIME = resolvePkgFile('@tarojs/plugin-framework-react/dist/runtime.js');
const REACT_DIR = resolvePkgDir('react');
const REACT_DOM_DIR = resolvePkgDir('react-dom');
const TARO_RUNTIME_DIR = resolvePkgDir('@tarojs/runtime');
const TARO_SHARED_DIR = resolvePkgDir('@tarojs/shared');
export default defineConfig(async () => ({
projectName: 'mini-user',
@@ -18,8 +38,13 @@ export default defineConfig(async () => ({
outputRoot: 'dist',
plugins: ['@tarojs/plugin-framework-react', '@tarojs/plugin-html'],
alias: {
react: path.resolve(__dirname, '../node_modules/react'),
'react-dom': path.resolve(__dirname, '../node_modules/react-dom'),
// 指向包目录(不是 index.js),否则 react-dom/client 会变成 index.js/client
react: REACT_DIR,
'react-dom': REACT_DOM_DIR,
'@tarojs/runtime': TARO_RUNTIME_DIR,
'@tarojs/shared': TARO_SHARED_DIR,
'@tarojs/plugin-framework-react/dist/runtime': TARO_FRAMEWORK_RUNTIME,
'@tarojs/plugin-framework-react/dist/runtime.js': TARO_FRAMEWORK_RUNTIME,
},
defineConstants: {
TARO_APP_API_ORIGIN: JSON.stringify(API_ORIGIN),
@@ -43,6 +68,16 @@ export default defineConfig(async () => ({
noDiscovery: true,
include: ['react', 'react-dom', 'react/jsx-runtime'],
},
resolve: {
// 防止 reactMetauseDidShow 依赖)在 vendors 里出现两份
dedupe: [
'react',
'react-dom',
'@tarojs/runtime',
'@tarojs/shared',
'@tarojs/plugin-framework-react',
],
},
build: {
sourcemap: false,
},
+2
View File
@@ -18,6 +18,8 @@ export default defineAppConfig({
'pages/redeem-code/index',
'pages/redeem-success/index',
'pages/login/index',
'pages/user-agreement/index',
'pages/privacy-policy/index',
],
window: {
backgroundTextStyle: 'light',
+16
View File
@@ -8,6 +8,7 @@
@import './styles/product-detail.css';
@import './styles/store-detail.css';
@import './styles/login.css';
@import './styles/legal.css';
@import './styles/order.css';
@import './styles/address.css';
@import './styles/redeem.css';
@@ -50,3 +51,18 @@ body::-webkit-scrollbar,
scrollbar-width: none;
-ms-overflow-style: none;
}
/* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */
.taro-tabbar__tabbar,
.taro-tabbar__border {
display: none !important;
height: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
.taro-tabbar__container,
.taro-tabbar__panel {
padding-bottom: 0 !important;
bottom: 0 !important;
}
+4
View File
@@ -1,8 +1,12 @@
import './lib/text-encoding-polyfill';
import { PropsWithChildren } from 'react';
import WechatShareBootstrap from './components/WechatShareBootstrap';
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
import './app.css';
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
patchTaroH5Hooks();
function App({ children }: PropsWithChildren) {
return (
<>
+6 -2
View File
@@ -10,6 +10,8 @@ type PageNavBarProps = {
right?: ReactNode;
};
const isH5 = process.env.TARO_ENV === 'h5';
/** 内页自定义导航栏(返回 + 标题 + 右侧操作),适配刘海屏 */
export default function PageNavBar({
title,
@@ -19,16 +21,18 @@ export default function PageNavBar({
right,
}: PageNavBarProps) {
const metrics = useNavBarMetrics();
const showTitle = !isH5 && titleVisible;
return (
<View
className={`page-nav-bar${solid ? ' page-nav-bar--solid' : ''}`}
style={subPageNavBarStyle(metrics)}
aria-label={title}
>
<Text
className={`page-nav-bar__title${titleVisible ? ' page-nav-bar__title--visible' : ''}`}
className={`page-nav-bar__title${showTitle ? ' page-nav-bar__title--visible' : ''}`}
>
{title}
{showTitle ? title : ''}
</Text>
<View
className="page-nav-bar__content"
@@ -0,0 +1,56 @@
.share-guide {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 12px 16px;
box-sizing: border-box;
}
.share-guide__arrow {
width: 0;
height: 0;
margin-right: 18px;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 14px solid #fff;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.12));
}
.share-guide__card {
margin-top: 0;
margin-right: 8px;
max-width: 260px;
padding: 16px 18px;
border-radius: 12px;
background: #fff;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
text-align: left;
}
.share-guide__title {
display: block;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
}
.share-guide__desc {
display: block;
font-size: 13px;
line-height: 1.5;
color: #666;
}
.share-guide__ok {
display: block;
margin-top: 14px;
text-align: right;
font-size: 14px;
font-weight: 600;
color: #a61d24;
}
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Button, Text, View } from '@tarojs/components';
import { handleShareButtonClick, type PageSharePayload } from '../lib/wechat-share';
import './ShareGuide.css';
type ShareNavButtonProps = {
payload?: PageSharePayload;
@@ -8,9 +10,11 @@ type ShareNavButtonProps = {
/**
*
* - open-type=share
* - H5 JSSDK ···
* - H5 JSSDK share /
*/
export default function ShareNavButton({ payload }: ShareNavButtonProps) {
const [guideVisible, setGuideVisible] = useState(false);
if (process.env.TARO_ENV === 'weapp') {
return (
<Button className="page-nav-bar__btn page-nav-bar__share-btn" openType="share" hoverClass="none">
@@ -20,13 +24,27 @@ export default function ShareNavButton({ payload }: ShareNavButtonProps) {
}
return (
<View
className="page-nav-bar__btn"
onClick={() => {
void handleShareButtonClick(payload);
}}
>
<Text className="page-nav-bar__icon page-nav-bar__icon--share"></Text>
</View>
<>
<View
className="page-nav-bar__btn"
onClick={() => {
void handleShareButtonClick(payload).then((res) => {
if (res.showGuide) setGuideVisible(true);
});
}}
>
<Text className="page-nav-bar__icon page-nav-bar__icon--share"></Text>
</View>
{guideVisible ? (
<View className="share-guide" onClick={() => setGuideVisible(false)}>
<View className="share-guide__arrow" />
<View className="share-guide__card">
<Text className="share-guide__title"></Text>
<Text className="share-guide__desc"> ··· </Text>
<Text className="share-guide__ok"></Text>
</View>
</View>
) : null}
</>
);
}
@@ -9,6 +9,8 @@ type SubPageHeaderProps = {
right?: ReactNode;
};
const isH5 = process.env.TARO_ENV === 'h5';
/** 子页面固定实底顶栏(确认订单 / 地址 / 支付 / 订单列表等) */
export default function SubPageHeader({ title, onBack, right }: SubPageHeaderProps) {
const metrics = useNavBarMetrics();
@@ -27,8 +29,8 @@ export default function SubPageHeader({ title, onBack, right }: SubPageHeaderPro
}
return (
<View className="sub-page-header" style={subPageNavBarStyle(metrics)}>
<Text className="sub-page-header__title">{title}</Text>
<View className="sub-page-header" style={subPageNavBarStyle(metrics)} aria-label={title}>
{!isH5 ? <Text className="sub-page-header__title">{title}</Text> : null}
<View
className="sub-page-header__content"
style={subPageNavContentStyle(metrics)}
@@ -7,13 +7,20 @@ type TabMainHeaderProps = {
extra?: ReactNode;
};
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让 */
const isH5 = process.env.TARO_ENV === 'h5';
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让;H5 不展示标题(与微信系统标题重复) */
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
const metrics = useNavBarMetrics();
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
if (isH5 && !extra) {
return null;
}
return (
<View className="tab-main-header" style={navBarStyle(metrics)}>
<Text className="tab-main-header__title">{title}</Text>
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
{!isH5 ? <Text className="tab-main-header__title">{title}</Text> : null}
<View
className="tab-main-header__content"
style={tabNavContentStyle(metrics)}
@@ -1,7 +1,7 @@
import Taro from '@tarojs/taro';
import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { useEffect, useRef } from 'react';
import { finishLoginNavigate, goLogin } from '../lib/auth-nav';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api';
import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share';
@@ -68,21 +68,27 @@ export default function WechatShareBootstrap() {
handleWechatAuthCallback()
.then((result) => {
if (!result) return;
if (saveWechatLoginResult(result)) {
toast('微信授权成功', 'success');
const ret = returnFromLogin || params.get('return') || undefined;
if (result.accountMerged) {
forceReloadAfterAccountMerge(ret);
return;
}
if (
returnFromLogin !== undefined ||
currentPagePathWithQuery().includes('/pages/login/')
) {
finishLoginNavigate(ret);
}
return;
}
// 兼容旧接口:仅 needBindPhone 时引导可选绑定,不阻塞浏览
if (result.needBindPhone && result.wxSessionKey) {
goLogin(returnFromLogin, {
bindMode: '1',
wxSessionKey: result.wxSessionKey,
});
return;
}
if (saveWechatLoginResult(result)) {
toast('微信授权成功', 'success');
if (
returnFromLogin !== undefined ||
currentPagePathWithQuery().includes('/pages/login/')
) {
finishLoginNavigate(returnFromLogin || params.get('return') || undefined);
}
}
})
.catch((e) => {
+30 -5
View File
@@ -1,6 +1,6 @@
import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
import { goLogin } from './auth-nav';
import { goLogin, forceReloadAfterAccountMerge } from './auth-nav';
function resolveApiBase(): string {
const origin =
@@ -18,7 +18,9 @@ function resolveApiBase(): string {
export const API_BASE = resolveApiBase();
const TOKEN_KEY = 'user_access_token';
const REFRESH_KEY = 'user_refresh_token';
export const CLIENT_APP = ClientApp.USER_MINI;
/** H5 产物走公众号体系(USER_H5);小程序原生走 USER_MINI。勿混用,否则 JSAPI 会出现 appid 与 openid 不匹配 */
export const CLIENT_APP =
process.env.TARO_ENV === 'h5' ? ClientApp.USER_H5 : ClientApp.USER_MINI;
export function getToken(): string {
try {
@@ -74,7 +76,18 @@ function parseBody(data: unknown): { code?: number; message?: string } {
return {};
}
/** 统一请求:注入 USER_MINI + Bearer,解包 { code, message, data } */
function isOnLoginPage(): boolean {
try {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as { route?: string } | undefined;
const route = cur?.route || '';
return route.includes('pages/login');
} catch {
return false;
}
}
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
const header: Record<string, string> = {
'Content-Type': 'application/json',
@@ -94,8 +107,18 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
const body = parseBody(res.data);
if (status === 401 || body?.code === 401) {
clearAuth();
redirectToLogin();
// 仅清掉「发起本请求时」仍在使用的 token,避免登录页旧 /auth/me 竞态清掉刚写入的新 token
const stillCurrent = !!token && getToken() === token;
if (stillCurrent) {
clearAuth();
const mergedMsg = body?.message || '';
if (/账号已合并/.test(mergedMsg)) {
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
forceReloadAfterAccountMerge();
} else if (!isOnLoginPage()) {
redirectToLogin();
}
}
throw new Error(body?.message || '登录已过期,请重新登录');
}
if (status === 404 && body.code === undefined) {
@@ -114,6 +137,8 @@ export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none'
export type SessionPayload = {
accessToken: string;
refreshToken?: string;
phoneVerified?: boolean;
accountMerged?: boolean;
};
export type UserProfile = {
+59 -9
View File
@@ -42,24 +42,74 @@ export function goLogin(returnPath?: string, extras?: Record<string, string>) {
/** 登录成功后回到 return 页,或回退 / 首页 */
export function finishLoginNavigate(returnTo?: string) {
const raw = (returnTo || '').trim();
const target = raw ? decodeURIComponent(raw) : '';
let target = '';
try {
target = raw ? decodeURIComponent(raw) : '';
} catch {
target = raw;
}
// 防止 return 仍指向登录页造成死循环
const pathOnly = target.split('?')[0];
if (pathOnly && TAB_PAGES.has(pathOnly)) {
Taro.switchTab({ url: pathOnly });
if (!pathOnly || pathOnly.includes('/pages/login')) {
Taro.reLaunch({ url: '/pages/home/index' });
return;
}
if (pathOnly && pathOnly.startsWith('/pages/')) {
if (TAB_PAGES.has(pathOnly)) {
Taro.switchTab({ url: pathOnly }).catch(() => {
Taro.reLaunch({ url: pathOnly });
});
return;
}
if (pathOnly.startsWith('/pages/')) {
Taro.redirectTo({ url: target }).catch(() => {
Taro.reLaunch({ url: pathOnly });
});
return;
}
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
Taro.reLaunch({ url: '/pages/home/index' });
}
/**
* /
* H5 URL location.replacereLaunch
*/
export function forceReloadAfterAccountMerge(returnTo?: string) {
const raw = (returnTo || '').trim();
let target = '';
try {
target = raw ? decodeURIComponent(raw) : '';
} catch {
target = raw;
}
const pathOnly = target.split('?')[0];
const safePath =
pathOnly && pathOnly.startsWith('/pages/') && !pathOnly.includes('/pages/login')
? target
: '/pages/home/index';
const launchPath = safePath.split('?')[0];
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
const { origin, pathname, search } = window.location;
const marker = '/pages/';
const idx = pathname.indexOf(marker);
let href: string;
if (idx >= 0) {
href = `${origin}${pathname.slice(0, idx)}${safePath}`;
} else if (window.location.hash.includes('/pages/')) {
href = `${origin}${pathname}${search}#${safePath}`;
} else {
const base = pathname.replace(/\/$/, '') || '';
href = `${origin}${base}${safePath.startsWith('/') ? safePath : `/${safePath}`}`;
}
window.location.replace(href);
return;
}
Taro.switchTab({ url: '/pages/home/index' });
if (TAB_PAGES.has(launchPath)) {
Taro.reLaunch({ url: launchPath });
return;
}
Taro.reLaunch({ url: safePath.startsWith('/') ? safePath : `/${safePath}` });
}
+2 -1
View File
@@ -29,7 +29,8 @@ export function buildAddressListUrl(ctx: CheckoutContext): string {
export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext): string {
const base = id ? `/pages/address-edit/index?id=${encodeURIComponent(id)}` : '/pages/address-edit/index';
const extra = buildQuery(ctx);
return extra ? `${base}&${extra}` : base;
if (!extra) return base;
return `${base}${base.includes('?') ? '&' : '?'}${extra}`;
}
export function buildPayUrl(params: {
@@ -0,0 +1,69 @@
/**
* H5Taro Vite @tarojs/plugin-framework-react/dist/runtime
* createReactApp B reactMeta Taro.useDidShow A R={}
* n.useContext is not a function
*
* App createReactApp runtime hooks Taro
*/
import Taro from '@tarojs/taro';
import {
useAddToFavorites,
useDidHide,
useDidShow,
useError,
useKeyboardHeight,
useLaunch,
useLoad,
useOptionMenuClick,
usePageNotFound,
usePageScroll,
usePullDownRefresh,
usePullIntercept,
useReachBottom,
useReady,
useResize,
useRouter,
useSaveExitState,
useScope,
useShareAppMessage,
useShareTimeline,
useTabItemTap,
useTitleClick,
useUnhandledRejection,
useUnload,
} from '@tarojs/plugin-framework-react/dist/runtime';
const HOOKS = {
useAddToFavorites,
useDidHide,
useDidShow,
useError,
useKeyboardHeight,
useLaunch,
useLoad,
useOptionMenuClick,
usePageNotFound,
usePageScroll,
usePullDownRefresh,
usePullIntercept,
useReachBottom,
useReady,
useResize,
useRouter,
useSaveExitState,
useScope,
useShareAppMessage,
useShareTimeline,
useTabItemTap,
useTitleClick,
useUnhandledRejection,
useUnload,
} as const;
export function patchTaroH5Hooks() {
if (process.env.TARO_ENV !== 'h5') return;
const target = Taro as unknown as Record<string, unknown>;
for (const [key, fn] of Object.entries(HOOKS)) {
target[key] = fn;
}
}
+9 -8
View File
@@ -5,7 +5,7 @@ import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pa
/**
*
* - /
* -
* - H5 openId OAuth
* - needWechat
*/
@@ -17,10 +17,6 @@ export async function ensurePayReady(returnPath: string): Promise<boolean> {
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
if (!profile.phoneVerified) {
goLogin(returnPath, { needPhone: '1' });
return false;
}
if (!needsWechatAuthForPay(config, profile)) {
return true;
}
@@ -28,8 +24,9 @@ export async function ensurePayReady(returnPath: string): Promise<boolean> {
if (process.env.TARO_ENV === 'h5') {
const auth = await ensureWechatAuthForPay();
if (auth.ok) return true;
// 旧版 needBindPhone 已不再返回;缺 openId 时走登录补微信绑定
if ('needBindPhone' in auth && auth.needBindPhone) {
goLogin(returnPath, { bindMode: '1', wxSessionKey: auth.wxSessionKey });
goLogin(returnPath, { needWechat: '1' });
return false;
}
// redirecting:正在跳转微信 OAuth
@@ -38,8 +35,12 @@ export async function ensurePayReady(returnPath: string): Promise<boolean> {
goLogin(returnPath, { needWechat: '1' });
return false;
} catch {
goLogin(returnPath);
} catch (e) {
// 仅会话失效时踢回登录;网络/业务错误不误清登录态
const msg = e instanceof Error ? e.message : '';
if (/登录已过期|重新登录|401/.test(msg) || !isLoggedIn()) {
goLogin(returnPath);
}
return false;
}
}
+60 -14
View File
@@ -1,11 +1,12 @@
import Taro from '@tarojs/taro';
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
import { API_BASE, getToken, request } from './api';
import { API_BASE, CLIENT_APP, getToken, request } from './api';
import { DEFAULT_REGION, regionFromGeo, type RegionSelection } from './region-data';
import { FALLBACK_CITY_CODE } from './product-images';
import { ClientApp } from '@dukang/shared-types';
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
/** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */
const LOCATION_DENIED_KEY = 'dukang_location_denied';
export type ResolvedUserCity = {
province: string;
@@ -31,7 +32,35 @@ const FALLBACK_CITY: ResolvedUserCity = {
displayCity: '郑州市',
};
let locationPrompted = false;
function isLocationDenied(): boolean {
try {
return Taro.getStorageSync(LOCATION_DENIED_KEY) === '1';
} catch {
return false;
}
}
function markLocationDenied() {
try {
Taro.setStorageSync(LOCATION_DENIED_KEY, '1');
} catch {
/* ignore */
}
}
function clearLocationDenied() {
try {
Taro.removeStorageSync(LOCATION_DENIED_KEY);
} catch {
/* ignore */
}
}
function isDenyMessage(errMsg?: string): boolean {
return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test(
errMsg || '',
);
}
function readCache(): GpsCityCache | null {
try {
@@ -56,6 +85,12 @@ function writeCache(data: ResolvedUserCity) {
}
}
/** 拒绝或失败后写入兜底城市,避免短时间内反复调起定位 */
function cacheFallbackAndMaybeDeny(denied: boolean) {
if (denied) markLocationDenied();
writeCache(FALLBACK_CITY);
}
async function reportLocationToServer(payload: {
latitude?: number;
longitude?: number;
@@ -99,14 +134,12 @@ function toResolved(data: {
};
}
async function promptLocationAuth() {
if (locationPrompted) return;
locationPrompted = true;
async function promptLocationAuthOnce() {
await Taro.showModal({
title: '位置授权',
content: '需要获取您的位置以展示所在城市的商品与门店',
confirmText: '去授权',
showCancel: true,
content: '需要获取您的位置以展示所在城市的商品与门店。拒绝后将默认使用郑州市,不会再次弹窗。',
confirmText: '知道了',
showCancel: false,
}).catch(() => {});
}
@@ -123,16 +156,19 @@ function getMiniLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
const outcome = await getWechatLocationDetailed({
apiBase: API_BASE,
clientApp: ClientApp.USER_H5,
clientApp: CLIENT_APP,
getAccessToken: () => getToken() || null,
});
if (!outcome.location) {
const denied = isDenyMessage(outcome.errMsg);
await reportLocationToServer({
sdk: outcome.sdk,
status: 'fail',
errMsg: outcome.errMsg,
}).catch(() => {});
// 失败一律缓存兜底,避免首页/门店每次进入再次调起微信定位弹窗
cacheFallbackAndMaybeDeny(denied);
return null;
}
@@ -144,9 +180,13 @@ async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
status: 'success',
});
const resolved = toResolved(data);
if (resolved) writeCache(resolved);
if (resolved) {
clearLocationDenied();
writeCache(resolved);
}
return resolved;
} catch {
cacheFallbackAndMaybeDeny(false);
return null;
}
}
@@ -154,6 +194,10 @@ async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
/** 获取并解析用户当前城市;失败返回郑州市兜底 */
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
if (!force) {
if (isLocationDenied()) {
const cached = readCache();
return cached ?? FALLBACK_CITY;
}
const cached = readCache();
if (cached) return cached;
}
@@ -177,20 +221,22 @@ export async function resolveUserCity(force = false): Promise<ResolvedUserCity>
});
const resolved = toResolved(data);
if (resolved) {
clearLocationDenied();
writeCache(resolved);
return resolved;
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const denied = /auth deny|authorize|permission|拒绝/i.test(errMsg);
if (denied) {
await promptLocationAuth();
const denied = isDenyMessage(errMsg);
if (denied && !isLocationDenied()) {
await promptLocationAuthOnce();
}
await reportLocationToServer({
sdk: 'jssdk',
status: 'fail',
errMsg: errMsg.slice(0, 200),
}).catch(() => {});
cacheFallbackAndMaybeDeny(denied);
}
return FALLBACK_CITY;
+25 -15
View File
@@ -6,7 +6,7 @@ import { toast } from './api';
import { isWechatEnv, weixinSdk } from './weixin';
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
export const DEFAULT_SHARE_DESC = '杜康好客 · 买酒享权益,全城门店可用';
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
export function getDefaultShareImageUrl(): string {
@@ -51,14 +51,17 @@ export type PageSharePayload = {
link?: string;
};
/** 点击「分享」按钮 */
export async function handleShareButtonClick(payload?: PageSharePayload): Promise<void> {
// 小程序:由 Button open-type="share" 触发,无需在此拉起
/**
*
* H5 JSSDK invoke
*/
export async function handleShareButtonClick(
payload?: PageSharePayload,
): Promise<{ showGuide: boolean }> {
if (process.env.TARO_ENV === 'weapp' || isMiniProgram()) {
try {
await Taro.showShareMenu({ withShareTicket: true, showShareItems: ['shareAppMessage', 'shareTimeline'] });
} catch {
/* 旧基础库可能不支持 showShareItems */
try {
await Taro.showShareMenu({ withShareTicket: true });
} catch {
@@ -66,24 +69,31 @@ export async function handleShareButtonClick(payload?: PageSharePayload): Promis
}
}
toast(WECHAT_SHARE_HINT);
return;
return { showGuide: false };
}
if (!isWechatEnv()) {
toast('请在微信内打开后分享');
return;
return { showGuide: false };
}
const data = buildDefaultShareData({
title: payload?.title,
desc: payload?.desc,
imgUrl: payload?.imgUrl,
link: payload?.link,
});
try {
await applyWechatShare({
title: payload?.title,
desc: payload?.desc,
imgUrl: payload?.imgUrl,
link: payload?.link,
});
const result = await weixinSdk.share(data);
if (result.invoked) {
return { showGuide: false };
}
toast(WECHAT_SHARE_HINT);
} catch {
toast('分享配置失败,请刷新后重试');
return { showGuide: true };
} catch (e) {
toast(e instanceof Error ? e.message : '分享配置失败,请刷新后重试');
return { showGuide: true };
}
}
+6 -5
View File
@@ -1,14 +1,15 @@
import { ClientApp } from '@dukang/shared-types';
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
import { API_BASE, getToken } from './api';
import { API_BASE, CLIENT_APP, getToken } from './api';
/**
* H5 JSSDK / USER_H5 appId
*
* H5 API CLIENT_APP
* - H5 USER_H5 OAuth openId + appId
* - USER_MINI
* JWT appid/openid
*/
export const weixinSdk = createWeixinSdk({
apiBase: API_BASE,
clientApp: process.env.TARO_ENV === 'h5' ? ClientApp.USER_H5 : ClientApp.USER_MINI,
clientApp: CLIENT_APP,
getAccessToken: () => getToken() || null,
});
@@ -82,6 +82,7 @@ export default function AddressEditPage() {
const validationError = validateForm();
if (validationError) {
setError(validationError);
toast(validationError);
return;
}
@@ -110,7 +111,9 @@ export default function AddressEditPage() {
});
}, 400);
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败');
const msg = e instanceof Error ? e.message : '保存失败';
setError(msg);
toast(msg);
} finally {
setSaving(false);
}
@@ -163,12 +166,26 @@ export default function AddressEditPage() {
</View>
<View className="address-form-field">
<Text className="address-form-label"></Text>
<Textarea
className="address-form-textarea"
placeholder="街道门牌号等"
value={form.detail}
onInput={(e) => setForm((prev) => ({ ...prev, detail: e.detail.value }))}
/>
{process.env.TARO_ENV === 'h5' ? (
<textarea
className="address-form-textarea address-form-textarea--native"
placeholder="街道门牌号等"
rows={3}
value={form.detail}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((prev) => ({ ...prev, detail: value }));
}}
/>
) : (
<Textarea
className="address-form-textarea"
placeholder="街道门牌号等"
value={form.detail}
maxlength={200}
onInput={(e) => setForm((prev) => ({ ...prev, detail: e.detail.value }))}
/>
)}
</View>
<View className="address-form-row">
<Text></Text>
+8 -2
View File
@@ -99,7 +99,9 @@ export default function AddressesPage() {
className="address-action"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: buildAddressEditUrl(a.id, checkoutCtx) });
Taro.navigateTo({ url: buildAddressEditUrl(a.id, checkoutCtx) }).catch((err) => {
toast(err instanceof Error ? err.message : '无法打开编辑页');
});
}}
>
@@ -120,7 +122,11 @@ export default function AddressesPage() {
</View>
<View
className="address-fab"
onClick={() => Taro.navigateTo({ url: buildAddressEditUrl(undefined, checkoutCtx) })}
onClick={() => {
Taro.navigateTo({ url: buildAddressEditUrl(undefined, checkoutCtx) }).catch((e) => {
toast(e instanceof Error ? e.message : '无法打开新增地址页');
});
}}
>
<Text></Text>
</View>
+4 -2
View File
@@ -63,8 +63,10 @@ export default function BenefitPage() {
return (
<PageShell variant="tab" className="benefit-page">
<View className="benefit-header" style={navBarStyle(metrics)}>
<Text className="benefit-header-title"></Text>
<View className="benefit-header" style={navBarStyle(metrics)} aria-label="好客权益">
{process.env.TARO_ENV !== 'h5' ? (
<Text className="benefit-header-title"></Text>
) : null}
<View
className="benefit-header__content"
style={tabNavContentStyle(metrics)}
+4 -1
View File
@@ -65,7 +65,10 @@ export default function HomePage() {
const onSale = tab === 'QINGXIANG';
return (
<PageShell variant="tab" className="home-page">
<PageShell
variant="tab"
className={`home-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="杜康好客" />
<View className="home-aroma-nav">
+126 -49
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { View, Text, Input, Button, Image } from '@tarojs/components';
import { useRouter } from '@tarojs/taro';
import { View, Text, Input, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import {
SmsScene,
isWxAuthorizeEnabled,
@@ -10,7 +10,7 @@ import {
import PageShell from '../../components/PageShell';
import WechatLoginButton from '../../components/WechatLoginButton';
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
import { finishLoginNavigate } from '../../lib/auth-nav';
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
import {
bindWechatForUser,
loginWithWechat,
@@ -47,7 +47,7 @@ export default function LoginPage() {
const [wxLoading, setWxLoading] = useState(false);
const [sending, setSending] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [agreed, setAgreed] = useState(true);
const [agreed, setAgreed] = useState(false);
const [msg, setMsg] = useState('');
const [sentHint, setSentHint] = useState('');
const [bindMode, setBindMode] = useState(initialBindMode);
@@ -66,8 +66,15 @@ export default function LoginPage() {
setCompleteMode(null);
return;
}
// 完善资料场景才拉 profile;普通登录勿抢跑 /auth/me,避免旧 token 401 与短信登录竞态
if (!needPhone && !needWechat) {
setCompleteMode(null);
return;
}
let cancelled = false;
fetchUserProfile()
.then((me) => {
if (cancelled) return;
if (needPhone && !me.phoneVerified) {
setCompleteMode('phone');
return;
@@ -76,13 +83,14 @@ export default function LoginPage() {
setCompleteMode('wechat');
return;
}
if (needPhone || needWechat) {
finishLoginNavigate(returnTo);
return;
}
setCompleteMode(null);
finishLoginNavigate(returnTo);
})
.catch(() => setCompleteMode(null));
.catch(() => {
if (!cancelled) setCompleteMode(null);
});
return () => {
cancelled = true;
};
}, [needPhone, needWechat, returnTo]);
useEffect(() => {
@@ -103,6 +111,7 @@ export default function LoginPage() {
data: SessionPayload | WechatLoginResult,
phone?: string,
wxInfo?: MiniWechatProfile | null,
successToast = '登录成功',
) {
if (!data.accessToken) return;
if (phone) saveUserPhone(phone);
@@ -116,22 +125,27 @@ export default function LoginPage() {
.then((me) => resolveDefaultUserPhone(me))
.catch(() => {});
}
toast('登录成功', 'success');
toast(successToast, 'success');
if (data.accountMerged) {
forceReloadAfterAccountMerge(returnTo);
return;
}
finishLoginNavigate(returnTo);
}
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
// 微信授权成功即登录;手机号改为下单页可选绑定
if (result.accessToken) {
applySessionAndLeave(result, undefined, wxInfo);
return;
}
if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setMsg('微信授权成功,绑定手机号完成登录');
setMsg('微信授权成功,绑定手机号(也可跳过,稍后在下单时再绑定)');
setSentHint('');
return;
}
if (result.accessToken) {
applySessionAndLeave(result, undefined, wxInfo);
return;
}
setMsg('微信登录未完成,请重试或使用手机号登录');
}
@@ -187,19 +201,26 @@ export default function LoginPage() {
return;
}
if (completeMode === 'phone' && isLoggedIn()) {
await request('/auth/phone/bind', {
// bind 返回新 session(合并账号后旧 guest JWT 立刻失效),必须落盘后再离开
const data = await request<SessionPayload>('/auth/phone/bind', {
method: 'POST',
data: { phone: normalized, code: code.trim() },
});
saveUserPhone(normalized);
toast('手机号验证成功', 'success');
finishLoginNavigate(returnTo);
if (!data?.accessToken) {
setMsg('手机号验证成功但会话未返回,请重新登录');
return;
}
applySessionAndLeave(data, normalized, null, '手机号验证成功');
return;
}
const data = await request<SessionPayload>('/auth/login/sms', {
method: 'POST',
data: { phone: normalized, code: code.trim() },
});
if (!data?.accessToken) {
setMsg('登录成功但未返回令牌,请重试');
return;
}
applySessionAndLeave(data, normalized);
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
@@ -286,13 +307,17 @@ export default function LoginPage() {
<View className="login-welcome">
<Text className="login-welcome-title">
{completeMode === 'phone'
? '完成手机验证'
? '建议绑定手机号'
: completeMode === 'wechat'
? '完成微信授权'
: '欢迎来到杜康好客'}
</Text>
<Text className="login-welcome-sub">
{completeMode ? '完成后将返回继续支付' : '买美酒,享好礼'}
{completeMode === 'phone'
? '便于订单通知与售后,也可稍后绑定'
: completeMode === 'wechat'
? '完成后将返回继续支付'
: '买美酒,享好礼'}
</Text>
</View>
</View>
@@ -304,6 +329,33 @@ export default function LoginPage() {
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
使
</Text>
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
{agreed ? <Text></Text> : null}
</View>
<Text className="login-agreement-text">
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/user-agreement/index' });
}}
>
</Text>
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
}}
>
</Text>
</Text>
</View>
{showWechatLogin ? (
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
) : null}
@@ -351,20 +403,59 @@ export default function LoginPage() {
</Text>
) : null}
<Button
className="login-sms-btn"
loading={loading}
disabled={loading}
onClick={() => void login()}
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
{agreed ? <Text></Text> : null}
</View>
<Text className="login-agreement-text">
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/user-agreement/index' });
}}
>
</Text>
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
}}
>
</Text>
</Text>
</View>
<View
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
onClick={loading ? undefined : () => void login()}
>
{loading
? '处理中...'
: completeMode === 'phone'
? '完成验证'
: bindMode
? '绑定并登录'
: '登录'}
</Button>
<Text className="login-sms-btn__text">
{loading
? '处理中...'
: completeMode === 'phone'
? '完成验证'
: bindMode
? '绑定并登录'
: '登录'}
</Text>
</View>
{completeMode === 'phone' ? (
<View
className="login-skip-bind"
onClick={() => finishLoginNavigate(returnTo)}
style={{ marginTop: 12, textAlign: 'center' }}
>
<Text className="u-muted" style={{ fontSize: 14 }}>
</Text>
</View>
) : null}
</View>
)}
@@ -379,20 +470,6 @@ export default function LoginPage() {
</>
) : null}
</View>
<View className="login-footer">
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
{agreed ? <Text></Text> : null}
</View>
<Text className="login-agreement-text">
<Text className="login-agreement-link"></Text>
<Text className="login-agreement-link"></Text>
</Text>
</View>
</View>
</PageShell>
);
}
+8 -2
View File
@@ -210,7 +210,10 @@ export default function MinePage() {
if (!authed) {
return (
<PageShell variant="tab" className="mine-page">
<PageShell
variant="tab"
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
@@ -247,7 +250,10 @@ export default function MinePage() {
const memberLabel = hasWechat ? '好客会员' : canWxBind ? '微信未授权' : '未授权微信';
return (
<PageShell variant="tab" className="mine-page">
<PageShell
variant="tab"
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
@@ -1,12 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { tryGetClientGpsLocation } from '../../lib/client-location';
import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
@@ -40,6 +42,9 @@ type OrderPreview = {
payAmount: number;
benefitAmount: number;
city?: { localMinQty: number; crossMinQty: number };
quantityOk?: boolean;
quantityMessage?: string | null;
minQty?: number;
};
function formatAddress(a: Address) {
@@ -51,13 +56,14 @@ export default function OrderConfirmPage() {
const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? '';
const forceCross = checkoutCtx.cross === true;
const [quantity, setQuantity] = useState(Math.max(2, Number(checkoutCtx.qty || 2)));
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
useEffect(() => {
request<Address[]>('/user/addresses')
@@ -88,7 +94,7 @@ export default function OrderConfirmPage() {
.then((data) => {
if (!cancelled) {
setPreview(data);
setMsg('');
setMsg(data.quantityOk === false ? (data.quantityMessage || '') : '');
}
})
.catch((e) => {
@@ -112,18 +118,14 @@ export default function OrderConfirmPage() {
);
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
const minQty =
preview?.minQty ??
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!addressId && !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < minQty) {
setMsg(
!isCross
? `同城配送至少购买 ${minQty}`
: `跨城配送至少购买 ${minQty} 瓶(1箱)`,
);
return;
}
setMsg('');
if (next < 1) return;
setQuantity(next);
}
@@ -155,12 +157,49 @@ export default function OrderConfirmPage() {
}
async function submit() {
if (!addressId) {
setMsg('请选择收货地址');
if (!canSubmit) {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
if (!quantityOk) {
setMsg(
isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`,
);
}
return;
}
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
if (!phonePromptSkipped.current) {
try {
const profile = await fetchUserProfile();
const phoneBound =
!!profile.phoneVerified ||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
if (!phoneBound) {
const { confirm, cancel } = await Taro.showModal({
title: '建议绑定手机号',
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
confirmText: '去绑定',
cancelText: '暂不绑定',
});
if (confirm) {
goLogin(returnPath, { needPhone: '1' });
return;
}
if (cancel) {
phonePromptSkipped.current = true;
}
}
} catch {
/* 拉取档案失败不阻塞下单 */
}
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
@@ -176,6 +215,13 @@ export default function OrderConfirmPage() {
}
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
const submitLabel = loading
? '提交中…'
: !addressId
? '请选择地址'
: !quantityOk
? `至少购买 ${minQty}`
: '提交订单';
return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
@@ -211,7 +257,8 @@ export default function OrderConfirmPage() {
{isCross ? (
<View className="order-card">
<Text className="u-muted">
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}
</Text>
</View>
) : null}
@@ -239,7 +286,7 @@ export default function OrderConfirmPage() {
<Text></Text>
<View className="order-qty-controls">
<View
className="order-qty-btn"
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
@@ -253,6 +300,13 @@ export default function OrderConfirmPage() {
</View>
</View>
</View>
{!quantityOk ? (
<Text className="order-qty-hint">
{isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
</Text>
) : null}
</View>
<View className="order-card">
@@ -290,10 +344,13 @@ export default function OrderConfirmPage() {
</Text>
</View>
<View
className="order-confirm-submit"
onClick={() => !loading && void submit()}
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
onClick={() => {
if (!canSubmit) return;
void submit();
}}
>
<Text>{loading ? '提交中…' : !addressId ? '请选择地址' : '提交订单'}</Text>
<Text>{submitLabel}</Text>
</View>
</View>
</PageShell>
+96 -13
View File
@@ -1,28 +1,66 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import Taro, { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type OrderItem = {
productName?: string;
productSpec?: string;
quantity?: number;
};
type OrderDetail = {
id: string;
orderNo?: string;
status?: string;
payAmount?: number;
productName?: string;
quantity?: number;
qty?: number;
addressSnapshot?: string;
receiverName?: string;
receiverPhone?: string;
receiverProvince?: string;
receiverCity?: string;
receiverDistrict?: string;
receiverAddress?: string;
createdAt?: string;
originOrderId?: string | null;
items?: OrderItem[];
};
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '出库中',
SHIPPING: '配送中',
PENDING_RECEIVE: '待签收',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
function fullReceiverAddress(order: OrderDetail) {
const detail = (order.receiverAddress || '').trim();
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
.filter(Boolean)
.join('');
if (!region && !detail) return '';
if (region && detail.startsWith(region)) return detail;
return `${region}${detail}`;
}
export default function OrderDetailPage() {
const router = useRouter();
const orderId = router.params.id ?? '';
@@ -35,15 +73,24 @@ export default function OrderDetailPage() {
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]);
const canPay = !!order && order.status === 'PENDING_PAY' && !order.originOrderId;
const item = order?.items?.[0];
const productName = item?.productName || order?.productName || '杜康商品';
const quantity = item?.quantity ?? order?.quantity ?? order?.qty ?? 1;
const addressText = order ? fullReceiverAddress(order) : '';
const receiverLine = order
? [order.receiverName, order.receiverPhone ? maskPhone(String(order.receiverPhone)) : '']
.filter(Boolean)
.join(' ')
: '';
const sharePayload = useMemo(
() => ({
title: order?.productName
? `我买了${order.productName} · 杜康好客`
: DEFAULT_SHARE_TITLE,
title: productName !== '杜康商品' ? `我买了${productName} · 杜康好客` : DEFAULT_SHARE_TITLE,
desc: DEFAULT_SHARE_DESC,
path: orderId ? `/pages/order-detail/index?id=${orderId}` : '/pages/home/index',
}),
[order, orderId],
[productName, orderId],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
@@ -52,8 +99,13 @@ export default function OrderDetailPage() {
query: orderId ? `id=${orderId}` : '',
}));
function goPay() {
if (!order) return;
Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) });
}
return (
<PageShell variant="sub" className="order-detail-page">
<PageShell variant="sub" className={`order-detail-page${canPay ? ' order-detail-page--with-pay' : ''}`}>
<WechatShareReady payload={sharePayload} />
<SubPageHeader
title="订单详情"
@@ -66,13 +118,15 @@ export default function OrderDetailPage() {
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="order-list-status">{order.status || '处理中'}</Text>
<Text className="order-list-status">
{STATUS_LABELS[order.status || ''] || order.status || '处理中'}
</Text>
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-row">
<Text className="order-row-label">{order.productName || '杜康商品'}</Text>
<Text className="order-row-value">x{order.qty ?? 1}</Text>
<Text className="order-row-label">{productName}</Text>
<Text className="order-row-value">x{quantity}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
@@ -83,9 +137,24 @@ export default function OrderDetailPage() {
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted">
{order.addressSnapshot || '地址信息待完善'}
</Text>
{receiverLine || addressText ? (
<>
{receiverLine ? (
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">{receiverLine}</Text>
</View>
) : null}
{addressText ? (
<View className="order-row order-row--address">
<Text className="order-row-label"></Text>
<Text className="order-row-value order-row-value--wrap">{addressText}</Text>
</View>
) : null}
</>
) : (
<Text className="u-muted"></Text>
)}
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
@@ -103,6 +172,20 @@ export default function OrderDetailPage() {
</>
)}
</View>
{canPay && order && (
<View className="pay-bar order-detail-pay-bar">
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{Number(order.payAmount ?? 0).toFixed(2)}
</Text>
</View>
<View className="order-confirm-submit" onClick={goPay}>
</View>
</View>
)}
</PageShell>
);
}

Some files were not shown because too many files have changed in this diff Show More