Compare commits

..

34 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
112 changed files with 5818 additions and 1518 deletions
+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` / 私钥打进仓库
- 用户只说「修好」未说发布时 → **只改代码,不发版**
+2
View File
@@ -90,6 +90,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project | | 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag | | preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* | | 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用) ## 子 Agent(按职责选用)
@@ -115,6 +116,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) | | `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag | | `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 | | `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令 ## 快速指令
+2
View File
@@ -18,6 +18,7 @@ import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage'; import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage'; import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage'; import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage'; import StoreMediaPage from './pages/StoreMediaPage';
import PromoCodesPage from './pages/PromoCodesPage'; import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout'; import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
@@ -74,6 +75,7 @@ export default function App() {
<Route path="/cities" element={<CitiesPage />} /> <Route path="/cities" element={<CitiesPage />} />
<Route path="/city-partners" element={<CityPartnersPage />} /> <Route path="/city-partners" element={<CityPartnersPage />} />
<Route path="/city-warehouses" element={<CityWarehousesPage />} /> <Route path="/city-warehouses" element={<CityWarehousesPage />} />
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} /> <Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} /> <Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} /> <Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
@@ -292,7 +292,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
subs={detail.children ?? []} subs={detail.children ?? []}
onAdd={() => { onAdd={() => {
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
}} }}
onEdit={(row) => { onEdit={(row) => {
@@ -56,6 +56,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/cities', label: '城市' }, { key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' }, { key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' }, { key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
], ],
}, },
{ {
+27 -1
View File
@@ -79,6 +79,11 @@ export type DashboardStats = {
partnersTotal: number; partnersTotal: number;
redeemToday: number; redeemToday: number;
deliveriesTotal: number; deliveriesTotal: number;
pendingPayouts?: number;
/** 合伙人已确认、待总部审核打款 */
pendingBills?: number;
pendingPartnerDraftBills?: number;
openTickets?: number;
ordersByStatus: Array<{ status: string; count: number }>; ordersByStatus: Array<{ status: string; count: number }>;
}; };
@@ -116,15 +121,36 @@ export type AdminUserRow = {
orderCount: number; orderCount: number;
}; };
export type AdminOrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
export type AdminOrderRow = { export type AdminOrderRow = {
id: string; id: string;
orderNo: string; orderNo: string;
status: string; status: string;
deliveryType: string; deliveryType: string;
payAmount: number; payAmount: number;
productName?: string;
productSpec?: string;
quantity?: number;
receiverName: string; receiverName: string;
receiverPhone: string; receiverPhone: string;
createdAt: string; createdAt: string;
cityId?: string;
city?: { id: string; name: string; code: string };
fulfillmentWarehouseId?: string | null;
fulfillmentWarehouse?: { id: string; name: string } | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null }; delivery?: {
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
logisticsCompany?: string | null;
manualQueryUrl?: string | null;
};
}; };
@@ -224,7 +224,7 @@ export default function CityPartnersPage() {
async function openAddSubAccount(parentId: string) { async function openAddSubAccount(parentId: string) {
await openPartner(parentId); await openPartner(parentId);
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
} }
@@ -482,7 +482,7 @@ export default function CityPartnersPage() {
subs={detail.children ?? []} subs={detail.children ?? []}
onAdd={() => { onAdd={() => {
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
}} }}
onEdit={(sub) => openSubEdit(sub, detail.id)} onEdit={(sub) => openSubEdit(sub, detail.id)}
+151 -42
View File
@@ -6,10 +6,12 @@ import {
Descriptions, Descriptions,
Form, Form,
Input, Input,
InputNumber,
Modal, Modal,
Popconfirm, Popconfirm,
Select, Select,
Space, Space,
Switch,
Table, Table,
Tag, Tag,
Typography, Typography,
@@ -17,10 +19,13 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
WAREHOUSE_FULFILLMENT_MODE_LABELS,
WAREHOUSE_MANAGER_LABELS, WAREHOUSE_MANAGER_LABELS,
WAREHOUSE_STATUS_LABELS, WAREHOUSE_STATUS_LABELS,
WarehouseFulfillmentMode,
WarehouseManagerType, WarehouseManagerType,
WarehouseStatus, WarehouseStatus,
type FulfillmentProviderDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
@@ -39,6 +44,13 @@ type Row = {
partnerAccountId: string | null; partnerAccountId: string | null;
partnerCompanyName?: string | null; partnerCompanyName?: string | null;
status: string; status: string;
fulfillmentMode?: string;
fulfillmentProviderId?: string | null;
fulfillmentProviderName?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
createdAt: string; createdAt: string;
}; };
@@ -48,6 +60,79 @@ type PartnerOption = { id: string; companyName: string };
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label })); const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label })); const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
function FulfillmentFields({
mode,
providerOptions,
onModeChange,
}: {
mode: WarehouseFulfillmentMode;
providerOptions: FulfillmentProviderDto[];
onModeChange: (mode: WarehouseFulfillmentMode) => void;
}) {
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
return (
<>
<Form.Item
name="fulfillmentMode"
label="支付后自动发货"
extra={
autoShip
? '同城订单支付成功后,自动推送仓配承运商(如小飞侠)并进入配送中'
: '支付后仅分配仓库,保持待发货,由仓管或总部手工填运单'
}
getValueProps={(v) => ({ checked: v === WarehouseFulfillmentMode.API_AUTO })}
getValueFromEvent={(checked: boolean) =>
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL
}
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
onModeChange(
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL,
);
}}
/>
</Form.Item>
{autoShip && (
<Form.Item
name="fulfillmentProviderId"
label="仓配承运商"
rules={[{ required: true, message: '自动发货须选择承运商' }]}
>
<Select
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
/>
</Form.Item>
)}
{!autoShip && (
<>
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
<Input placeholder="如 顺丰速运" />
</Form.Item>
<Form.Item
name="manualQueryUrlTemplate"
label="物流查询链接模板"
extra="可用 {trackingNo} 占位符"
>
<Input placeholder="https://example.com/track?no={trackingNo}" />
</Form.Item>
</>
)}
<Space>
<Form.Item name="lng" label="经度">
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
</Form.Item>
<Form.Item name="lat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
);
}
export default function CityWarehousesPage() { export default function CityWarehousesPage() {
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
@@ -73,6 +158,13 @@ export default function CityWarehousesPage() {
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [createCityId, setCreateCityId] = useState<string | undefined>(); const [createCityId, setCreateCityId] = useState<string | undefined>();
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
const loadCities = useCallback(async () => { const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -88,6 +180,9 @@ export default function CityWarehousesPage() {
useEffect(() => { useEffect(() => {
void loadCities(); void loadCities();
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
.then(setProviderOptions)
.catch(() => {});
}, [loadCities]); }, [loadCities]);
async function openEdit(row: Row) { async function openEdit(row: Row) {
@@ -102,23 +197,29 @@ export default function CityWarehousesPage() {
managerType: row.managerType, managerType: row.managerType,
partnerAccountId: row.partnerAccountId, partnerAccountId: row.partnerAccountId,
status: row.status, status: row.status,
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
lng: row.lng ?? undefined,
lat: row.lat ?? undefined,
}); });
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
setEditOpen(true); setEditOpen(true);
} }
function onManagerTypeChange( function onManagerTypeChange(
type: WarehouseManagerType, v: WarehouseManagerType,
form: typeof createForm | typeof editForm, form: typeof createForm,
setType: (v: WarehouseManagerType) => void, setType: (t: WarehouseManagerType) => void,
) { ) {
setType(type); setType(v);
if (type !== WarehouseManagerType.PARTNER) { if (v !== WarehouseManagerType.PARTNER) {
form.setFieldValue('partnerAccountId', undefined); form.setFieldValue('partnerAccountId', undefined);
} }
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
{ {
title: '城市', title: '城市',
width: 100, width: 100,
@@ -128,17 +229,17 @@ export default function CityWarehousesPage() {
</span> </span>
), ),
}, },
{ title: '地址', dataIndex: 'address', ellipsis: true }, { title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 }, { title: '电话', dataIndex: 'contactPhone', width: 120 },
{ {
title: '管仓类型', title: '管仓',
dataIndex: 'managerType', width: 90,
width: 100, render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
}, },
{ {
title: '管仓合伙人', title: '合伙人',
dataIndex: 'partnerCompanyName', dataIndex: 'partnerCompanyName',
width: 120, width: 120,
ellipsis: true, ellipsis: true,
@@ -149,6 +250,16 @@ export default function CityWarehousesPage() {
'—' '—'
), ),
}, },
{
title: '自动发货',
width: 130,
render: (_, row) =>
row.fulfillmentMode === 'API_AUTO' ? (
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
) : (
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
),
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -190,21 +301,23 @@ export default function CityWarehousesPage() {
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
</Typography.Title>
<Button <Button
type="primary" type="primary"
onClick={() => { onClick={() => {
createForm.resetFields(); createForm.resetFields();
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
setCreateManagerType(WarehouseManagerType.HQ); setCreateManagerType(WarehouseManagerType.HQ);
setCreateCityId(undefined); setCreateCityId(undefined);
setPartners([]); setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
createForm.setFieldsValue({
managerType: WarehouseManagerType.HQ,
status: WarehouseStatus.ACTIVE,
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
});
setCreateOpen(true); setCreateOpen(true);
}} }}
> >
</Button> </Button>
</Space> </Space>
@@ -212,7 +325,7 @@ export default function CityWarehousesPage() {
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。" message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
/> />
<Form <Form
@@ -225,7 +338,7 @@ export default function CityWarehousesPage() {
}} }}
> >
<Form.Item name="name" label="仓库名"> <Form.Item name="name" label="仓库名">
<Input allowClear placeholder="仓库名" /> <Input allowClear />
</Form.Item> </Form.Item>
<Form.Item name="cityId" label="城市"> <Form.Item name="cityId" label="城市">
<Select <Select
@@ -233,47 +346,33 @@ export default function CityWarehousesPage() {
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
style={{ width: 140 }} style={{ width: 140 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))} options={cities.map((c) => ({ value: c.id, label: c.name }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="managerType" label="管仓类型"> <Form.Item name="managerType" label="管仓">
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} /> <Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={STATUS_OPTIONS} /> <Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Space> <Button type="primary" htmlType="submit">
<Button type="primary" htmlType="submit">
</Button>
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item> </Form.Item>
</Form> </Form>
<Table <Table
rowKey="id" rowKey="id"
className="admin-table-nowrap"
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 1400 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
total: data?.total ?? 0, total: data?.total ?? 0,
showSizeChanger: true, showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => { onChange: (p, ps) => {
setPage(p); setPage(p);
setPageSize(ps); setPageSize(ps);
@@ -282,7 +381,7 @@ export default function CityWarehousesPage() {
/> />
<Modal <Modal
title="新仓库" title="新仓库"
open={createOpen} open={createOpen}
width={520} width={520}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
@@ -341,6 +440,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}> <Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<FulfillmentFields
mode={createFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setCreateFulfillmentMode}
/>
</Form> </Form>
</Modal> </Modal>
@@ -399,6 +503,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<FulfillmentFields
mode={editFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setEditFulfillmentMode}
/>
</Form> </Form>
</Modal> </Modal>
</div> </div>
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message, Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
} from 'antd'; } from 'antd';
@@ -164,6 +165,47 @@ export default function DashboardPage() {
</Card> </Card>
</Col> </Col>
</Row> </Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待审核合伙人打款"
extra={<Link to="/finance/partner-bills"></Link>}
>
<Statistic
value={stats?.pendingBills ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
<Typography.Text type="secondary">
{(stats?.pendingPartnerDraftBills ?? 0) > 0
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
: ''}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待打款门店结算"
extra={<Link to="/finance/store-bills"></Link>}
>
<Statistic
value={stats?.pendingPayouts ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card loading={loading} title="待处理工单">
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<Card title="已合并访客账号" loading={loading}> <Card title="已合并访客账号" loading={loading}>
@@ -0,0 +1,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, Input,
InputNumber, InputNumber,
Modal, Modal,
Radio,
Select, Select,
Space, Space,
Table, Table,
@@ -17,8 +18,14 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type AdminOrderRow, type Paginated } from '../lib/api'; import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api';
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
ORDER_STATUS_COLORS,
ORDER_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
type ShipDefaults = { type ShipDefaults = {
provider: string; provider: string;
@@ -33,6 +40,21 @@ type ShipDefaults = {
payMode: string; 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 & { type OrderDetail = AdminOrderRow & {
receiverAddress?: string; receiverAddress?: string;
receiverProvince?: string; receiverProvince?: string;
@@ -53,16 +75,60 @@ type OrderDetail = AdminOrderRow & {
benefitAmount?: number; benefitAmount?: number;
paidAt?: string | null; paidAt?: string | null;
payExpireAt?: string | null; payExpireAt?: string | null;
items?: Array<Record<string, unknown>>; productImage?: string;
listUnitPrice?: number;
items?: AdminOrderItem[];
payment?: Record<string, unknown> | null; payment?: Record<string, unknown> | null;
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>; statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
benefitCoupons?: Array<Record<string, unknown>>; 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() { export default function OrdersPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [shipForm] = Form.useForm(); const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm();
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null); const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -74,6 +140,12 @@ export default function OrdersPage() {
const [batchDeleting, setBatchDeleting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false);
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null); const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
const [shipping, setShipping] = useState(false); 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( const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), () => (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) }); const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (values.orderNo) qs.set('orderNo', values.orderNo); if (values.orderNo) qs.set('orderNo', values.orderNo);
if (values.status) qs.set('status', values.status); if (values.status) qs.set('status', values.status);
if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`); const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res); setData(res);
@@ -101,40 +174,100 @@ export default function OrdersPage() {
useEffect(() => { useEffect(() => {
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {}); 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) { async function openDetail(id: string) {
const res = await request<OrderDetail>(`/admin/orders/${id}`); const res = await request<OrderDetail>(`/admin/orders/${id}`);
setDetail(res); setDetail(res);
setDrawerOpen(true); 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) { if (defaults) {
setShipDefaults(defaults); setShipDefaults(defaults);
shipForm.setFieldsValue({ applyShipDefaults(defaults, preferred?.id);
provider: defaults.provider, } else {
weight: defaults.weight, shipForm.setFieldsValue({ warehouseId: preferred?.id, provider: 'XFX' });
payMode: defaults.payMode,
fromName: defaults.fromName,
fromMobile: defaults.fromMobile,
fromAddress: defaults.fromAddress,
fromAddressDetail: defaults.fromAddressDetail,
fromLng: defaults.fromLng,
fromLat: defaults.fromLat,
});
} }
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() { async function submitShip() {
if (!detail) return; if (!shipTarget) return;
const values = await shipForm.validateFields(); const values = await shipForm.validateFields();
setShipping(true); setShipping(true);
try { try {
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/ship`, { const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/ship`, {
method: 'POST', 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); setDetail(res);
setShipModalOpen(false);
void load(); void load();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '发货失败'); 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() { async function confirmBatchDelete() {
if (!selectedRowKeys.length) return; if (!selectedRowKeys.length) return;
setBatchDeleting(true); setBatchDeleting(true);
@@ -168,6 +332,25 @@ export default function OrdersPage() {
const columns: ColumnsType<AdminOrderRow> = [ const columns: ColumnsType<AdminOrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180 }, { 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: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -204,11 +387,19 @@ export default function OrdersPage() {
}, },
{ {
title: '操作', title: '操作',
width: 80, width: 140,
fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}> <Space size={0}>
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
</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="状态"> <Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item> </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="收货手机"> <Form.Item name="receiverPhone" label="收货手机">
<Input allowClear /> <Input allowClear />
</Form.Item> </Form.Item>
@@ -248,7 +449,7 @@ export default function OrdersPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 1500 }}
rowSelection={{ rowSelection={{
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
@@ -272,6 +473,11 @@ export default function OrdersPage() {
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
<Space> <Space>
{canShip(detail) && (
<Button type="primary" onClick={() => void openShipModal(detail.id)}>
</Button>
)}
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
<Select <Select
value={detail.status} value={detail.status}
@@ -295,6 +501,13 @@ export default function OrdersPage() {
<> <>
<Descriptions column={1} bordered size="small" title="基本信息"> <Descriptions column={1} bordered size="small" title="基本信息">
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item> <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="状态"> <Descriptions.Item label="状态">
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}> <Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
{ORDER_STATUS_LABELS[detail.status] || detail.status} {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.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
</Descriptions> </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 column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item> <Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item> <Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
@@ -324,74 +582,22 @@ export default function OrdersPage() {
{detail.delivery && ( {detail.delivery && (
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}> <Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
<Descriptions.Item label="快递公司"> <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>
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item> <Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</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> </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 && ( {detail.statusLogs && detail.statusLogs.length > 0 && (
<> <>
<Typography.Title level={5} style={{ marginTop: 16 }}></Typography.Title> <Typography.Title level={5} style={{ marginTop: 16 }}></Typography.Title>
@@ -412,6 +618,150 @@ export default function OrdersPage() {
)} )}
</Drawer> </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 <Modal
title={`确认批量删除(${selectedOrders.length} 笔)`} title={`确认批量删除(${selectedOrders.length} 笔)`}
open={batchDeleteOpen} open={batchDeleteOpen}
@@ -437,13 +787,27 @@ export default function OrdersPage() {
dataSource={selectedOrders} dataSource={selectedOrders}
columns={[ columns={[
{ title: '订单号', dataIndex: 'orderNo', width: 170 }, { 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: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 100,
render: (s) => ( 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: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 }, { title: '收货人', dataIndex: 'receiverName', width: 90 },
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
function openAddSub(parent: AccountTreeRow) { function openAddSub(parent: AccountTreeRow) {
setSubParent(parent); setSubParent(parent);
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
} }
+93 -16
View File
@@ -4,6 +4,7 @@ import {
Card, Card,
DatePicker, DatePicker,
Form, Form,
Input,
Modal, Modal,
Select, Select,
Space, Space,
@@ -29,6 +30,7 @@ type Row = {
status: string; status: string;
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string }; partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string }; partnerAccount?: { companyName?: string; phone?: string };
}; };
@@ -36,15 +38,17 @@ type Row = {
type PartnerOption = { id: string; companyName: string; phone?: string }; type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿', DRAFT: '待合伙人确认',
CONFIRMED: '已确认', CONFIRMED: '待打款审核',
PAID: '已打款', PAID: '已打款',
REJECTED: '已驳回',
}; };
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default', DRAFT: 'default',
CONFIRMED: 'blue', CONFIRMED: 'orange',
PAID: 'green', PAID: 'green',
REJECTED: 'red',
}; };
export default function PartnerBillsPage() { export default function PartnerBillsPage() {
@@ -66,6 +70,10 @@ export default function PartnerBillsPage() {
const [genOpen, setGenOpen] = useState(false); const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm(); const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
useEffect(() => { useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -95,7 +103,7 @@ export default function PartnerBillsPage() {
async function confirmBill(id: string) { async function confirmBill(id: string) {
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' }); await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
message.success('已确认'); message.success('已确认,进入待打款审核');
reload(); reload();
} }
@@ -104,10 +112,33 @@ export default function PartnerBillsPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
}); });
message.success('已标记打款'); message.success('已通过并标记打款');
reload(); 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() { async function exportExcel() {
setExporting(true); setExporting(true);
try { try {
@@ -163,24 +194,38 @@ export default function PartnerBillsPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 90, width: 120,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>, render: (s, row) => (
<Space direction="vertical" size={0}>
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
{s === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
),
}, },
{ {
title: '操作', title: '操作',
width: 160, width: 220,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0} wrap>
{row.status === 'DRAFT' && ( {row.status === 'DRAFT' && (
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}> <Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
</Button> </Button>
)} )}
{row.status === 'CONFIRMED' && ( {row.status === 'CONFIRMED' && (
<Button type="link" size="small" onClick={() => void markPaid(row.id)}> <>
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button>
</Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
</>
)} )}
</Space> </Space>
), ),
@@ -194,7 +239,7 @@ export default function PartnerBillsPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
T+30 × × T+30
</Typography.Text> </Typography.Text>
</Space> </Space>
@@ -242,7 +287,7 @@ export default function PartnerBillsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select <Select
allowClear allowClear
style={{ width: 120 }} style={{ width: 140 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
@@ -280,7 +325,7 @@ export default function PartnerBillsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 1280 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -320,6 +365,38 @@ export default function PartnerBillsPage() {
</Button> </Button>
</Form> </Form>
</Modal> </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> </div>
); );
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space, Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
Tabs, Tag, Typography, message, Tabs, Tag, Typography, message,
@@ -15,6 +16,8 @@ type XfxConfig = {
hasApiKey: boolean; hasApiKey: boolean;
signType: string; signType: string;
ready: boolean; ready: boolean;
source?: string;
hint?: string;
}; };
type ApiResult = { type ApiResult = {
@@ -115,8 +118,9 @@ export default function XiaofeixiaTestPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
HQ APIcmd 100101~100301 HQ APIcmd 100101~100301
{' '}<code>XIAOFEIXIA_MCH_ID</code><code>XIAOFEIXIA_API_KEY</code> <Link to="/fulfillment-providers"></Link>
使
</Typography.Paragraph> </Typography.Paragraph>
{config && ( {config && (
@@ -132,13 +136,17 @@ export default function XiaofeixiaTestPage() {
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
{config.ready ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>} {config.ready ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="来源" span={3}>
{config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'}
{config.hint ? ` · ${config.hint}` : ''}
</Descriptions.Item>
</Descriptions> </Descriptions>
{!config.ready && ( {!config.ready && (
<Alert <Alert
type="warning" type="warning"
showIcon showIcon
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
message="请在 server/dukang-api/.env 中配置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY 后重启 API" message="请先在仓配管理中注册小飞侠并填写 API 地址、商户号与 API Key"
/> />
)} )}
</Card> </Card>
+22 -9
View File
@@ -1,6 +1,6 @@
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { usePartnerSession } from './contexts/PartnerSessionContext'; import { usePartnerSession } from './contexts/PartnerSessionContext';
import { isSubAccount } from './lib/partnerAccess'; import { getPartnerNavKind, isSubAccount } from './lib/partnerAccess';
import TabLayout from './layouts/TabLayout'; import TabLayout from './layouts/TabLayout';
import SubAccountLayout from './layouts/SubAccountLayout'; import SubAccountLayout from './layouts/SubAccountLayout';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
@@ -13,7 +13,6 @@ import CenterPage from './pages/CenterPage';
import PartnerMePage from './pages/PartnerMePage'; import PartnerMePage from './pages/PartnerMePage';
import BillsPage from './pages/BillsPage'; import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage'; import SettlementPage from './pages/SettlementPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage'; import WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage'; import ProxyOrderPage from './pages/ProxyOrderPage';
import StaffListPage from './pages/StaffListPage'; import StaffListPage from './pages/StaffListPage';
@@ -26,7 +25,6 @@ function PrimaryRoutes() {
<Route element={<TabLayout />}> <Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stores" element={<StoreListPage />} /> <Route path="/stores" element={<StoreListPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/center" element={<CenterPage />} /> <Route path="/center" element={<CenterPage />} />
</Route> </Route>
<Route path="/stores/new" element={<StoreCreatePage />} /> <Route path="/stores/new" element={<StoreCreatePage />} />
@@ -35,10 +33,10 @@ function PrimaryRoutes() {
<Route path="/center/staff" element={<StaffListPage />} /> <Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} /> <Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} /> <Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/reshipments" element={<ReshipPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} /> <Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} /> <Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} /> <Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} /> <Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@@ -46,15 +44,30 @@ function PrimaryRoutes() {
} }
function SubAccountRoutes() { function SubAccountRoutes() {
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isWarehouse = navKind === 'warehouse_staff';
return ( return (
<Routes> <Routes>
<Route element={<SubAccountLayout />}> <Route element={<SubAccountLayout navKind={navKind} />}>
<Route path="/stores" element={<StoreListPage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stores/new" element={<StoreCreatePage />} /> {isWarehouse ? (
<Route path="/stores/:id" element={<StoreDetailPage />} /> <Route path="/orders" element={<OrderListPage tabRoot />} />
) : (
<Route path="/stores" element={<StoreListPage />} />
)}
<Route path="/me" element={<PartnerMePage />} /> <Route path="/me" element={<PartnerMePage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} /> {!isWarehouse && (
<>
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
</>
)}
<Route path="/leaderboard" element={<LeaderboardPage />} />
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );
} }
@@ -50,7 +50,13 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
staffRole: profile.staffRole, staffRole: profile.staffRole,
permissions: profile.permissions, permissions: profile.permissions,
primaryAccountId: profile.primaryAccountId, primaryAccountId: profile.primaryAccountId,
primaryPhone: profile.primaryPhone,
primaryName: profile.primaryName,
hasWechat: profile.hasWechat, hasWechat: profile.hasWechat,
wxNickname: profile.wxNickname,
wxAvatarUrl: profile.wxAvatarUrl,
managedWarehouseId: profile.managedWarehouseId,
hasWarehouseAccess: profile.hasWarehouseAccess,
}; };
} }
@@ -1,17 +1,30 @@
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import type { PartnerNavKind } from '../lib/partnerAccess';
const TABS = [ const STORE_STAFF_TABS = [
{ to: '/stores', end: true, icon: 'store', label: '门店管理' }, { to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/stores/new', icon: 'add_business', label: '录入门店' }, { to: '/stores', icon: 'store', label: '门店管理' },
{ to: '/me', icon: 'person', label: '我的' }, { to: '/me', icon: 'person', label: '个人中心' },
] as const; ] as const;
export default function SubAccountLayout() { const WAREHOUSE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/orders', icon: 'receipt_long', label: '订单管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] as const;
type SubAccountLayoutProps = {
navKind: PartnerNavKind;
};
export default function SubAccountLayout({ navKind }: SubAccountLayoutProps) {
const tabs = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_TABS : STORE_STAFF_TABS;
return ( return (
<> <>
<Outlet /> <Outlet />
<nav className="app-tabbar"> <nav className="app-tabbar">
{TABS.map((tab) => ( {tabs.map((tab) => (
<NavLink <NavLink
key={tab.to} key={tab.to}
to={tab.to} to={tab.to}
+20 -1
View File
@@ -24,7 +24,20 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
export type PartnerSessionProfile = Pick< export type PartnerSessionProfile = Pick<
PartnerMe, PartnerMe,
'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'hasWechat' | 'id'
| 'name'
| 'phone'
| 'companyName'
| 'isPrimary'
| 'permissions'
| 'primaryAccountId'
| 'primaryPhone'
| 'primaryName'
| 'hasWechat'
| 'wxNickname'
| 'wxAvatarUrl'
| 'managedWarehouseId'
| 'hasWarehouseAccess'
> & { > & {
staffRole?: PartnerStaffRole; staffRole?: PartnerStaffRole;
}; };
@@ -109,7 +122,13 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
staffRole: me.staffRole ?? undefined, staffRole: me.staffRole ?? undefined,
permissions: me.permissions, permissions: me.permissions,
primaryAccountId: me.primaryAccountId, primaryAccountId: me.primaryAccountId,
primaryPhone: me.primaryPhone,
primaryName: me.primaryName,
hasWechat: me.hasWechat, hasWechat: me.hasWechat,
wxNickname: me.wxNickname,
wxAvatarUrl: me.wxAvatarUrl,
managedWarehouseId: me.managedWarehouseId,
hasWarehouseAccess: me.hasWarehouseAccess,
}; };
} }
+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>( return request<PartnerLeaderboardResponse>(
'PARTNER_H5', 'PARTNER_H5',
`/partner/dashboard/leaderboard?period=${period}`, `/partner/dashboard/leaderboard?period=${period}`,
{ silent: true },
); );
} }
+80 -5
View File
@@ -1,5 +1,7 @@
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types'; import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean { export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
return account?.isPrimary !== false; return account?.isPrimary !== false;
} }
@@ -17,15 +19,88 @@ export function hasPartnerPermission(
return account.permissions?.includes(permission) ?? false; return account.permissions?.includes(permission) ?? false;
} }
export function partnerHomePath(account: PartnerMe | null | undefined): string { export function hasAnyPartnerPermission(
return isSubAccount(account) ? '/stores/new?step=1' : '/'; account: PartnerMe | null | undefined,
permissions: PartnerPermissionKey[],
): boolean {
return permissions.some((p) => hasPartnerPermission(account, p));
} }
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/me', '/login']; /** 与后端 GET /partner/dashboard 权限点一致 */
export function canAccessPartnerDashboard(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, [
'store:create',
'store:manage',
'order:view',
'warehouse:manage',
]);
}
export function isSubAccountPath(pathname: string): boolean { /** 门店列表/拓店统计所需权限 */
export function canAccessPartnerStores(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
}
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 录入新店 */
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasPartnerPermission(account, 'store:create')) return true;
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 与后端 GET /partner/orders 权限点一致 */
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
}
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
if (!account || isPrimaryAccount(account)) return false;
if (hasPartnerPermission(account, 'warehouse:manage')) return true;
const hasStore =
hasPartnerPermission(account, 'store:create') || hasPartnerPermission(account, 'store:manage');
if (hasPartnerPermission(account, 'order:view') && !hasStore) return true;
return false;
}
export function getPartnerNavKind(account: PartnerMe | null | undefined): PartnerNavKind {
if (!account || isPrimaryAccount(account)) return 'primary';
if (isWarehouseStaff(account)) return 'warehouse_staff';
return 'store_staff';
}
/** 是否已配置管仓:主账号看 hasWarehouseAccess;子账号还需有仓库相关权限 */
export function hasWarehouseAccess(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (account.hasWarehouseAccess === false) return false;
if (account.hasWarehouseAccess === true) return true;
// 旧缓存无字段时保守:主账号未知视为无,避免误展示全城订单
return false;
}
export function partnerHomePath(account: PartnerMe | null | undefined): string {
return '/';
}
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
if (pathname === '/login') return true; if (pathname === '/login') return true;
return SUB_ACCOUNT_ALLOWED_PREFIXES.some( const prefixes = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_PREFIXES : STORE_STAFF_PREFIXES;
return prefixes.some(
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)), (prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
); );
} }
+6 -7
View File
@@ -1,9 +1,5 @@
import { import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
PartnerStaffRole, import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
type CreatePartnerStaffRequest,
type PartnerStaffItem,
type UpdatePartnerStaffRequest,
} from '@dukang/shared-types';
import { request } from './api'; import { request } from './api';
export function listPartnerStaff() { export function listPartnerStaff() {
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
name: body.name, name: body.name,
phone: body.phone, phone: body.phone,
smsCode: body.smsCode, smsCode: body.smsCode,
staffRole: PartnerStaffRole.INTERNAL, staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: body.permissions?.length
? body.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
}), }),
}); });
} }
+4
View File
@@ -14,6 +14,8 @@ export type PartnerProfile = {
phone: string; phone: string;
companyName?: string; companyName?: string;
hasWechat?: boolean; hasWechat?: boolean;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
isPrimary?: boolean; isPrimary?: boolean;
}; };
@@ -82,6 +84,8 @@ export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessio
companyName: partner.companyName ? String(partner.companyName) : undefined, companyName: partner.companyName ? String(partner.companyName) : undefined,
isPrimary: partner.isPrimary !== false, isPrimary: partner.isPrimary !== false,
hasWechat: true, hasWechat: true,
wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined,
wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined,
} }
: undefined, : undefined,
}; };
+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 { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function billStatusLabel(status: string) {
switch (status) {
case '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() { export default function BillsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]); const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
async function loadBills() {
setLoading(true);
try {
const list = await request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills');
setBills(Array.isArray(list) ? list : []);
} catch {
setBills([]);
} finally {
setLoading(false);
}
}
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills); void loadBills();
}, [navigate]); }, [navigate]);
const billId = searchParams.get('id'); const billId = searchParams.get('id');
const actionable = useMemo(
() => bills.filter((b) => canApplyPayment(b.status)),
[bills],
);
const bill = billId const bill = billId
? bills.find((b) => String(b.id) === billId) ?? bills[0] ? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
: bills[0]; : actionable[0] ?? bills[0];
function confirmBill() { const status = String(bill?.status || '');
if (!confirmed) return; const showApplyForm = !!bill && 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); 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); setSubmitting(false);
alert('申请已提交,请耐心等待打款审核。(preV1 演示)'); }
}, 1500);
} }
return ( return (
<div className="partner-bills-page"> <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-bill-stepper">
<div className="partner-stepper-inner"> <div className="partner-stepper-inner">
<div className="partner-stepper-line" aria-hidden> <div className="partner-stepper-line" aria-hidden>
<div className="partner-stepper-line-fill" style={{ width: '50%' }} /> <div
className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isReviewing || isRejected ? '75%' : '50%' }}
/>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done"> <div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
@@ -45,17 +99,28 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--active">2</div> <div className={`partner-step-circle partner-step-circle--sm${showApplyForm || 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> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm">3</div> <div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
<span className="partner-step-label"></span> {isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'}
</div>
<span className={`partner-step-label${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'}
</span>
</div> </div>
</div> </div>
</div> </div>
{!bill && <div className="empty"></div>} {loading && <div className="empty"></div>}
{!loading && !bill && <div className="empty"></div>}
{bill && ( {bill && (
<section className="partner-bill-card"> <section className="partner-bill-card">
@@ -66,25 +131,46 @@ export default function BillsPage() {
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p> <p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2> <h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
</div> </div>
<span className="partner-status-pill partner-status-pill--paused">{String(bill.status || '待确认')}</span> <span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
</span>
</div> </div>
{isRejected && bill.rejectReason && (
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}></p>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
</div>
</div>
)}
{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"> <div className="partner-bill-amount">
<p className="text-muted body-md" style={{ marginBottom: 8 }}></p> <p className="text-muted body-md" style={{ marginBottom: 8 }}></p>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span> <span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
<span className="amount-xl">{Number(bill.totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span className="amount-xl">{fmtMoney(Number(bill.totalAmount || 0))}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="partner-info-row"> <div className="partner-info-row">
<span className="text-variant body-md"></span> <span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.82).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
</div> </div>
<div className="partner-info-row"> <div className="partner-info-row">
<span className="text-variant body-md"></span> <span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.18).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -93,45 +179,58 @@ export default function BillsPage() {
{bills.length > 1 && ( {bills.length > 1 && (
<div style={{ padding: '0 20px' }}> <div style={{ padding: '0 20px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3> <h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
{bills.slice(1).map((b) => ( {bills.map((b) => (
<div key={String(b.id)} className="partner-store-card" style={{ margin: '0 0 12px' }}> <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 className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div> <div>
<p className="body-md">{String(b.billNo)}</p> <p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{String(b.status)}</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> </div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span> <span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
</div> </div>
</div> </button>
))} ))}
</div> </div>
)} )}
<div className="partner-info-banner" style={{ marginTop: 16 }}> {showApplyForm && (
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span> <>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}> <div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
</p> <p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</div> {isRejected
? '请根据驳回理由核对后重新确认并申请打款。'
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
</p>
</div>
{bill && ( <footer className="partner-bill-footer">
<footer className="partner-bill-footer"> <label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}> <input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} /> <span></span>
<span></span> </label>
</label> <button
<button type="button"
type="button" className="partner-btn-primary"
className="partner-btn-primary" disabled={!confirmed || submitting}
disabled={!confirmed || submitting} style={{ opacity: confirmed ? 1 : 0.5 }}
style={{ opacity: confirmed ? 1 : 0.5 }} onClick={() => void confirmBill()}
onClick={confirmBill} >
> {submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'}
{submitting ? '正在提交...' : '确认并申请打款'} {!submitting && <span className="material-symbols-outlined">payments</span>}
{!submitting && <span className="material-symbols-outlined">payments</span>} </button>
</button> </footer>
</footer> </>
)} )}
</div> </div>
); );
+265 -133
View File
@@ -1,157 +1,289 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { contactSupport } from '../lib/contact';
import { isPrimaryAccount } from '../lib/partnerAccess'; import { isPrimaryAccount } from '../lib/partnerAccess';
import { listPartnerStaff } from '../lib/staff';
import { toastError, toastSuccess } from '../lib/toast';
import {
authorizePartnerWechat,
fetchClientConfig,
handlePartnerWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
export default function CenterPage() { type CenterPageProps = {
const navigate = useNavigate(); /** 子账号个人中心复用 */
const { account, logout } = usePartnerSession(); variant?: 'primary' | 'sub';
const showPrimaryMenus = isPrimaryAccount(account); roleLabel?: string;
const me = account as unknown as Record<string, unknown> | null; };
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
const { account, logout, refresh, applySession } = usePartnerSession();
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
const [authorizing, setAuthorizing] = useState(false);
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [staffCount, setStaffCount] = useState(0);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } document.title = isPrimary ? '合伙人中心' : '个人中心';
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills); }, [isPrimary]);
}, [navigate]);
const pendingBills = bills.filter((b) => String(b.status).includes('PENDING') || String(b.status).includes('CONFIRM')); 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 ( return (
<div className="page partner-center-page"> <div className="page partner-center-page partner-home--flush-top">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<section className="partner-profile-card"> <section className="partner-profile-card">
<div className="partner-profile-avatar"> <button
<span className="material-symbols-outlined">person</span> type="button"
</div> className="partner-profile-avatar"
<div style={{ flex: 1 }}> onClick={() => void handleAvatarAuth()}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> disabled={authorizing}
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(me?.name || '合伙人')}</h2> aria-label={avatarUrl ? '更新微信头像' : '微信授权获取头像'}
<span className="partner-role-badge"></span> >
{avatarUrl ? (
<img src={avatarUrl} alt="" className="partner-profile-avatar-img" referrerPolicy="no-referrer" />
) : (
<span className="material-symbols-outlined">person</span>
)}
{!avatarUrl && (
<span className="partner-profile-avatar-hint"></span>
)}
</button>
<div className="partner-profile-main">
<div className="partner-profile-title-row">
<h2 className="partner-profile-name">{displayName}</h2>
<span className="partner-role-badge">{badgeLabel}</span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}> <div className="partner-profile-location">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
<span className="body-md">{String(me?.companyName || '郑州')}</span> <span className="body-md line-2-clamp">{locationLabel}</span>
</div> </div>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(me?.phone || '')}</p> {!isPrimary && (
</div> <p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
</section>
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 20px 8px' }}>
<h3 className="headline-md"></h3>
<Link to="/center/bills" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center' }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
</Link>
</div>
<Link to="/center/bills" className="partner-bills-banner">
<div className="partner-bills-banner-left">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">pending_actions</span>
</div>
<div>
<p className="body-md" style={{ fontWeight: 500 }}></p>
<p className="label-md text-primary"> {pendingBills.length || bills.length} </p>
</div>
</div>
<span className="material-symbols-outlined text-primary">chevron_right</span>
</Link>
<div className="partner-finance-grid">
<div className="partner-finance-card partner-finance-card--hero">
<p className="label-md" style={{ opacity: 0.8, marginBottom: 4 }}> ()</p>
<span className="amount-xl" style={{ color: '#fff', fontSize: 32 }}>
{bills.length > 0 ? Number(bills[0].totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) : '0.00'}
</span>
</div>
<div className="partner-finance-card">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-md">
<span className="text-primary">¥</span>
{pendingBills.reduce((s, b) => s + Number(b.totalAmount || 0), 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</p>
</div>
<div className="partner-finance-card">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="headline-md">{bills.length} </span>
<span className="material-symbols-outlined text-muted">history</span>
</div>
</div>
</div>
</section>
<section className="partner-menu-section">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div className="partner-menu-card">
<Link to="/stores" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">store</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/orders" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">receipt_long</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/center/settlement" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/reports/weekly" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">monitoring</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/reshipments" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">assignment_return</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/leaderboard" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">military_tech</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
{showPrimaryMenus && (
<Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
)} )}
</div> </div>
</section> </section>
{isPrimary && (
<>
<section className="partner-assets-section">
<div className="partner-section-head">
<h3 className="headline-md"></h3>
<Link to="/center/settlement" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
{finance.pendingBillCount > 0 && (
<Link to="/center/bills" className="partner-bills-banner">
<div className="partner-bills-banner-left">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">receipt_long</span>
</div>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600 }}>
{finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'}
</p>
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
{finance.rejectedCount > 0
? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)`
: `您有 ${finance.pendingBillCount} 笔账单待确认`}
</p>
</div>
</div>
<span className="material-symbols-outlined text-primary">chevron_right</span>
</Link>
)}
<div className="partner-finance-grid">
<div className="partner-finance-card partner-finance-card--hero">
<p className="partner-finance-hero-label"> ()</p>
<p className="partner-finance-hero-amount">{fmtMoney(finance.balance)}</p>
</div>
<Link to="/center/settlement" className="partner-finance-card">
<p className="label-md text-muted"></p>
<p className="partner-finance-value text-primary">¥ {fmtMoney(finance.pendingTotal)}</p>
</Link>
<Link to="/center/settlement" className="partner-finance-card">
<p className="label-md text-muted" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>history</span>
</p>
<p className="partner-finance-value">{finance.paidCount} </p>
</Link>
</div>
</section>
<section className="partner-menu-section">
<div className="partner-section-head">
<h3 className="headline-md"></h3>
</div>
<div className="partner-menu-card">
<Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">badge</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div className="partner-menu-item-right">
{staffCount > 0 && <span className="partner-menu-badge">{staffCount}</span>}
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/stores?audit=pending" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">storefront</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/stores" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">description</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div className="partner-menu-item-right">
<span className="partner-menu-status"></span>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<button
type="button"
className="partner-menu-item"
onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}
>
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">support_agent</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
</div>
</section>
</>
)}
{!isPrimary && (
<section className="partner-menu-section">
<div className="partner-menu-card">
<Link to="/leaderboard" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">emoji_events</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
</div>
</section>
)}
<button type="button" className="partner-logout-btn" onClick={logout}> <button type="button" className="partner-logout-btn" onClick={logout}>
<span className="material-symbols-outlined">logout</span> <span className="material-symbols-outlined">logout</span>
退 退
</button> </button>
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}> <div className="partner-center-footer">
<p className="label-md text-muted"> · </p> <p className="label-md text-muted"> · </p>
</div> </div>
</div> </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 { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types'; import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus'; import {
canAccessPartnerDashboard,
canAccessPartnerOrders,
canAccessPartnerStores,
getPartnerNavKind,
hasWarehouseAccess,
} from '../lib/partnerAccess';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
function fmtMoney(n: number) { function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
const HOME_STORE_PREVIEW_LIMIT = 6; const LEADERBOARD_PREVIEW_LIMIT = 3;
function OrderSummarySection({
title,
todayCount,
pendingShip,
shipping,
completed,
ordersLink = '/orders',
}: {
title?: string;
todayCount: number;
pendingShip: number;
shipping: number;
completed: number;
ordersLink?: string;
}) {
return (
<section>
<div className="partner-order-summary-header">
<h2 className="headline-md">{title || `今日订单 ${todayCount}`}</h2>
<Link to={ordersLink} className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
<div className="partner-order-stats">
<div className="partner-order-stat">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
</div>
<div className="partner-order-stat partner-order-stat--blue">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
</div>
<div className="partner-order-stat partner-order-stat--green">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
</div>
</div>
</section>
);
}
function isPendingShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
function isShipping(status: string) {
const s = status.toUpperCase();
return s.includes('SHIP') || s === 'OUT_WAREHOUSE' || s === 'PENDING_RECEIVE';
}
function isCompleted(status: string) {
return status.toUpperCase() === 'COMPLETED';
}
function isToday(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function summarizeOrders(orders: Array<Record<string, unknown>>) {
let pendingShip = 0;
let shipping = 0;
let completed = 0;
let todayCount = 0;
for (const o of orders) {
const status = String(o.status || '');
if (isToday(String(o.createdAt || ''))) todayCount += 1;
if (isPendingShip(status)) pendingShip += 1;
else if (isShipping(status)) shipping += 1;
else if (isCompleted(status)) completed += 1;
}
return { pendingShip, shipping, completed, todayCount };
}
function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) {
return (
<section className="partner-bento-card partner-leaderboard-preview">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
{entries.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
entries.map((entry) => (
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div className={`partner-leaderboard-avatar partner-leaderboard-avatar--rank-${Math.min(entry.rank, 3)}`}>
<span className="material-symbols-outlined">person</span>
<span className="partner-leaderboard-avatar-badge">{entry.rank}</span>
</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
</p>
<p className="label-md text-muted">: {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
))
)}
</section>
);
}
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isPrimary = navKind === 'primary';
const isWarehouse = navKind === 'warehouse_staff';
const warehouseOk = hasWarehouseAccess(account);
const canOrders = canAccessPartnerOrders(account) && warehouseOk && (isPrimary || isWarehouse);
const canDashboard = canAccessPartnerDashboard(account) && !isWarehouse;
const canStores = canAccessPartnerStores(account) && !isWarehouse;
const [dash, setDash] = useState<Record<string, unknown> | null>(null); const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]); const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]); const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
const [notifOpen, setNotifOpen] = useState(false); const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
useEffect(() => {
document.title = '工作台';
}, []);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash); // 等 session 带上账号后再按权限发请求,避免无权限接口弹错
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores); 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') fetchPartnerLeaderboard('month')
.then((data) => setLeaderboardPreview(data.list.slice(0, 2))) .then((data) => {
.catch(() => setLeaderboardPreview([])); setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
}, [navigate]); })
.catch(() => {
setLeaderboardEntries([]);
});
}, [navigate, account, canOrders, canDashboard, canStores]);
const storeCount = Number(dash?.storeCount || stores.length || 0); const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderCount = Number(dash?.orderCount || 0); const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
const pendingAuditCount = Number(dash?.pendingAuditCount || 0); const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
const notifications = Array.isArray(dash?.notifications)
? (dash.notifications as Array<Record<string, unknown>>)
: [];
const hasUnread = notifications.length > 0;
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length; const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
const abnormalStores = Math.max(0, storeCount - activeStores); const abnormalStores = Math.max(0, storeCount - activeStores);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
const pendingShip = Math.ceil(orderCount * 0.04);
const shipping = Math.ceil(orderCount * 0.12);
const completed = Math.max(0, orderCount - pendingShip - shipping);
const monthNew = stores.filter((s) => { const monthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || '')); const created = new Date(String(s.createdAt || ''));
const now = new Date(); const now = new Date();
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear(); return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
}).length; }).length;
const lastMonthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || ''));
const now = new Date();
const last = new Date(now.getFullYear(), now.getMonth() - 1, 1);
return created.getMonth() === last.getMonth() && created.getFullYear() === last.getFullYear();
}).length;
const monthGrowthPct = lastMonthNew > 0
? Math.round(((monthNew - lastMonthNew) / lastMonthNew) * 100)
: (monthNew > 0 ? 100 : 0);
const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT); const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
return ( return (
<div className="page partner-home"> <div className="page partner-home partner-home--flush-top">
<header className="partner-home-header">
<h1 className="app-page-title"></h1>
<div className="partner-home-header-actions">
<button
type="button"
className="partner-notif-btn"
aria-label="通知"
onClick={() => setNotifOpen((v) => !v)}
>
<span className="material-symbols-outlined">notifications</span>
{hasUnread && <span className="partner-notif-dot" />}
</button>
<div className="partner-profile-avatar" style={{ width: 40, height: 40 }}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person</span>
</div>
</div>
</header>
{notifOpen && (
<section className="partner-form-card" style={{ margin: '0 16px 12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 className="headline-md"></h2>
<button type="button" className="label-md text-muted" onClick={() => setNotifOpen(false)}></button>
</div>
{notifications.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
notifications.slice(0, 8).map((n) => (
<Link
key={String(n.id)}
to={`/stores/${n.storeId}`}
className="partner-home-store-row"
style={{ marginBottom: 8 }}
onClick={() => setNotifOpen(false)}
>
<div className="partner-home-store-info">
<p className="body-md" style={{ fontWeight: 600 }}>{String(n.title || '门店审核')}</p>
<p className="label-md text-muted">{String(n.content || '')}</p>
</div>
</Link>
))
)}
</section>
)}
<main className="partner-home-body"> <main className="partner-home-body">
<section className="partner-revenue-card"> {isPrimary && (
<p className="partner-revenue-label"> <>
(CNY) <section className="partner-revenue-card">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span> <p className="partner-revenue-label">
</p> (CNY)
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div> <span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
<div className="partner-revenue-grid"> </p>
<div> <div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
<p className="partner-revenue-label"></p> <div className="partner-revenue-grid">
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div>
</div>
</section>
<div className="partner-bento">
<section className="partner-bento-card">
<div className="partner-bento-header">
<h2 className="headline-md"></h2>
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
</div>
<div className="partner-store-stats">
<div className="partner-store-stat">
<span className="partner-dot partner-dot--green" />
<div> <div>
<p className="label-md text-muted"></p> <p className="partner-revenue-label"></p>
<p className="headline-md">{activeStores}</p> <p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div> </div>
</div> </div>
<div className="partner-store-stat"> </section>
<span className="partner-dot partner-dot--red" /> <section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div> <div className="partner-quick-actions">
<p className="label-md text-muted">/</p> <Link to="/proxy-order" className="partner-quick-action">
<p className="headline-md">{abnormalStores}</p> <div className="partner-quick-action-icon partner-quick-action-icon--red">
</div> <span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
</div>
</section>
<section className="partner-bento-card">
<div className="partner-quick-actions">
<Link to="/proxy-order" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/stores/new" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">add_business</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reshipments" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
<span className="material-symbols-outlined">assignment_return</span>
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/center/settlement" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--green">
<span className="material-symbols-outlined">account_balance</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reports/weekly" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
<span className="material-symbols-outlined">monitoring</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
</div>
</section>
</div>
<section>
<div className="partner-order-summary-header">
<h2 className="headline-md"> {orderCount}</h2>
<Link to="/orders" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
<div className="partner-order-stats">
<div className="partner-order-stat">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
</div>
<div className="partner-order-stat partner-order-stat--blue">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
</div>
<div className="partner-order-stat partner-order-stat--green">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
</div>
</div>
</section>
<section className="partner-home-store-section">
<div className="partner-home-store-header">
<h2 className="headline-md"></h2>
<Link to="/stores" className="label-md text-primary"></Link>
</div>
{previewStores.length === 0 ? (
<div className="partner-home-store-empty">
<p className="label-md text-muted"></p>
<Link to="/stores/new" className="label-md text-primary"></Link>
</div>
) : (
previewStores.map((s) => {
const storeId = String(s.id);
const status = String(s.status || 'PAUSED').toUpperCase();
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
return (
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
<div className="partner-home-store-info">
<p className="headline-md">{String(s.name || '未命名门店')}</p>
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
</div> </div>
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}> <span className="partner-quick-action-label"></span>
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
</span>
</Link> </Link>
); <Link to="/stores/new" className="partner-quick-action">
}) <div className="partner-quick-action-icon partner-quick-action-icon--red">
)} <span className="material-symbols-outlined">add_business</span>
</section>
<section className="partner-expansion-card">
<h2 className="headline-md" style={{ marginBottom: 16 }}></h2>
<div className="partner-expansion-split">
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
</div>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
</div>
</div>
<div className="partner-leaderboard-section">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary"></Link>
</div>
{leaderboardPreview.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
leaderboardPreview.map((entry) => (
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
</p>
<p className="label-md text-muted"> {entry.totalStores} </p>
</div> </div>
<div className="partner-leaderboard-stat"> <span className="partner-quick-action-label"></span>
<p className="headline-md text-primary">{entry.periodStores} </p> </Link>
<p className="label-md text-muted"></p> <Link to="/center/settlement" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--green">
<span className="material-symbols-outlined">account_balance</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reports/weekly" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
<span className="material-symbols-outlined">monitoring</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
</div>
</section>
</>
)}
{isWarehouse && (
<p className="headline-md" style={{ marginBottom: 8 }}></p>
)}
{canOrders && (
<OrderSummarySection
title={isWarehouse ? '今日订单' : undefined}
todayCount={orderStats.todayCount}
pendingShip={orderStats.pendingShip}
shipping={orderStats.shipping}
completed={orderStats.completed}
/>
)}
{(isPrimary || isWarehouse) && !warehouseOk && (
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-warehouse-denied">
<span className="material-symbols-outlined text-primary">warehouse</span>
<p className="body-md" style={{ fontWeight: 600, marginTop: 8 }}></p>
<p className="label-md text-muted" style={{ marginTop: 4, lineHeight: 1.5 }}>
</p>
</div>
</section>
)}
{canStores && (
<>
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-bento-header">
<h2 className="headline-md"></h2>
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
</div>
<div className="partner-store-stats">
<div className="partner-store-stat">
<span className="partner-dot partner-dot--green" />
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{activeStores}</p>
</div> </div>
</div> </div>
)) <div className="partner-store-stat">
)} <span className="partner-dot partner-dot--red" />
</div> <div>
<p className="label-md text-muted">/</p>
<p className="headline-md">{abnormalStores}</p>
</div>
</div>
</div>
</section>
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}> <section className="partner-expansion-card">
{String(dash?.companyName || '郑州合伙人')} · <h2 className="headline-md" style={{ marginBottom: 16 }}></h2>
</p> <div className="partner-expansion-split">
</section> <div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div className="partner-expansion-value-row">
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
{monthGrowthPct !== 0 && (
<span className={`partner-expansion-trend${monthGrowthPct > 0 ? ' partner-expansion-trend--up' : ' partner-expansion-trend--down'}`}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>
{monthGrowthPct > 0 ? 'trending_up' : 'trending_down'}
</span>
{Math.abs(monthGrowthPct)}%
</span>
)}
</div>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
</div>
</div>
</section>
</>
)}
<LeaderboardPreview entries={leaderboardEntries} />
</main> </main>
</div> </div>
); );
@@ -47,7 +47,7 @@ export default function LeaderboardPage() {
return ( return (
<div className="page-no-tab"> <div className="page-no-tab">
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} /> <PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
<div className="partner-leaderboard-tabs"> <div className="partner-leaderboard-tabs">
{PERIOD_TABS.map((tab) => ( {PERIOD_TABS.map((tab) => (
+42 -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 { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; 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_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account'; 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) { function maskPhone(phone: string) {
if (phone.length < 7) return phone; if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`; return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
@@ -74,6 +100,7 @@ export default function LoginPage() {
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false); const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => { useEffect(() => {
fetchClientConfig() fetchClientConfig()
@@ -88,6 +115,7 @@ export default function LoginPage() {
function ensureAgreed() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先勾选并同意用户协议'); setMsg('请先勾选并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false; return false;
} }
return true; return true;
@@ -230,10 +258,16 @@ export default function LoginPage() {
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}> <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>} {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 ? ( {canWechatQuick ? (
<button <button
type="button" type="button"
className="partner-btn-primary" className="partner-btn-primary"
style={{ marginTop: 16 }}
disabled={wxLoading} disabled={wxLoading}
onClick={() => void wechatLogin()} onClick={() => void wechatLogin()}
> >
@@ -241,7 +275,7 @@ export default function LoginPage() {
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span> <span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button> </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() {wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录' ? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'} : '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
@@ -321,6 +355,12 @@ export default function LoginPage() {
<span></span> <span></span>
</label> </label>
<AgreementCheckbox
agreed={agreed}
onChange={setAgreed}
inputRef={agreementRef}
/>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>} {msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}> <button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
@@ -339,20 +379,6 @@ export default function LoginPage() {
</button> </button>
</> </>
)} )}
<label className="partner-checkbox-row">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(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>
</div> </div>
</main> </main>
+63 -50
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
const TIMELINE = [ const TIMELINE = [
{ key: 'confirm', label: '待确认' }, { key: 'confirm', label: '待确认' },
@@ -28,21 +29,55 @@ function statusBanner(status: string) {
return { title: status, desc: '', icon: 'receipt_long' }; return { title: status, desc: '', icon: 'receipt_long' };
} }
function canShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
export default function OrderDetailPage() { export default function OrderDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [order, setOrder] = useState<Record<string, unknown> | null>(null); const [order, setOrder] = useState<Record<string, unknown> | null>(null);
const [shipping, setShipping] = useState(false);
useEffect(() => { useEffect(() => {
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder); document.title = '订单详情';
}, []);
useEffect(() => {
if (id) void request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
}, [id]); }, [id]);
async function reload() {
if (!id) return;
const next = await request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`);
setOrder(next);
}
async function advance(status: string) { async function advance(status: string) {
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, { if (!id) return;
method: 'POST', setShipping(true);
body: JSON.stringify({ targetStatus: status }), try {
}); await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder); method: 'POST',
body: JSON.stringify({ targetStatus: status }),
});
await reload();
toastSuccess('已更新配送状态');
} finally {
setShipping(false);
}
}
function handleContactCourier() {
const delivery = order?.delivery as Record<string, unknown> | undefined;
const trackingNo = String(delivery?.trackingNo || delivery?.providerOrderNo || '').trim();
if (trackingNo) {
void navigator.clipboard?.writeText(trackingNo);
toastSuccess(`运单号已复制:${trackingNo}`);
return;
}
toastError('暂无配送员联系方式');
} }
if (!order) return <div className="empty">...</div>; if (!order) return <div className="empty">...</div>;
@@ -50,6 +85,7 @@ export default function OrderDetailPage() {
const banner = statusBanner(String(order.status)); const banner = statusBanner(String(order.status));
const currentIdx = statusIndex(String(order.status)); const currentIdx = statusIndex(String(order.status));
const payAmount = Number(order.payAmount || 0); const payAmount = Number(order.payAmount || 0);
const showShip = canShip(String(order.status));
return ( return (
<div className="partner-order-detail"> <div className="partner-order-detail">
@@ -91,9 +127,6 @@ export default function OrderDetailPage() {
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}> <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}>
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span> <span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
</div> </div>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(166,29,36,0.8)', textAlign: 'center', padding: '2px 0' }}>
<span className="label-md" style={{ color: '#fff', fontSize: 10 }}></span>
</div>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3> <h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
@@ -106,39 +139,6 @@ export default function OrderDetailPage() {
</div> </div>
</section> </section>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, margin: '0 20px' }}>
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-heritage-red)' }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<div className="partner-info-row">
<span className="label-md text-muted"></span>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
</div>
<div className="partner-info-row">
<span className="label-md text-muted"></span>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
</div>
</div>
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-aged-amber)' }}>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="amount-lg" style={{ color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</span>
</div>
</div>
<section className="partner-detail-section">
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
</h4>
<div className="partner-info-row">
<span className="text-muted body-md"></span>
<span className="body-md">{String(order.orderNo)}</span>
</div>
<div className="partner-info-row">
<span className="text-muted body-md"></span>
<span className="body-md">{String(order.status)}</span>
</div>
</section>
<section className="partner-detail-section"> <section className="partner-detail-section">
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}> <h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} /> <span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
@@ -156,21 +156,34 @@ export default function OrderDetailPage() {
</section> </section>
<footer className="partner-order-footer"> <footer className="partner-order-footer">
<div> <div className="partner-order-footer-actions">
<span className="label-md text-muted"></span> {showShip && (
<p className="headline-md text-primary" style={{ fontWeight: 700 }}>¥{(payAmount * 0.08).toFixed(2)}</p> <button
type="button"
className="btn btn-primary partner-order-ship-btn--footer"
disabled={shipping}
onClick={() => void advance('OUT_WAREHOUSE')}
>
{shipping ? '发货中…' : '确认发货'}
</button>
)}
<button
type="button"
className="btn btn-outline"
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}
onClick={handleContactCourier}
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
</button>
</div> </div>
<button type="button" className="btn btn-outline" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
</button>
</footer> </footer>
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<details className="partner-dev-tools"> <details className="partner-dev-tools">
<summary>Dev: Mock </summary> <summary>Dev: Mock </summary>
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => ( {['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
<button key={s} type="button" onClick={() => advance(s)}>{s}</button> <button key={s} type="button" onClick={() => void advance(s)}>{s}</button>
))} ))}
</details> </details>
)} )}
+173 -42
View File
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { hasWarehouseAccess } from '../lib/partnerAccess';
type DateFilter = 'today' | '7d' | '30d'; type DateFilter = 'today' | '7d' | '30d';
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL'; type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
@@ -29,17 +31,52 @@ const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ABNORMAL', label: '异常' }, { key: 'ABNORMAL', label: '异常' },
]; ];
export default function OrderListPage() { type OrderListPageProps = {
/** Tab 根页:无返回顶栏 */
tabRoot?: boolean;
};
type OrdersResponse = {
list: Array<Record<string, unknown>>;
hasWarehouseAccess?: boolean;
message?: string;
};
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] }); const { account } = usePartnerSession();
const warehouseOk = hasWarehouseAccess(account);
const [data, setData] = useState<OrdersResponse>({ list: [] });
const [tab, setTab] = useState<'orders' | 'coupons'>('orders'); const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
const [dateFilter, setDateFilter] = useState<DateFilter>('today'); const [dateFilter, setDateFilter] = useState<DateFilter>('today');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [shippingId, setShippingId] = useState<string | null>(null);
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
const [shipModalId, setShipModalId] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
document.title = '订单管理';
}, []);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
request('PARTNER_H5', '/partner/orders').then(setData); if (!warehouseOk) {
}, [navigate]); 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) => { const filtered = useMemo(() => data.list.filter((o) => {
if (statusFilter === 'ALL') return true; if (statusFilter === 'ALL') return true;
@@ -51,13 +88,68 @@ export default function OrderListPage() {
return true; return true;
}), [data.list, statusFilter]); }), [data.list, statusFilter]);
async function handleShip(orderId: string, e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setShipModalId(orderId);
setShipForm({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
}
async function submitManualShip() {
if (!shipModalId) return;
if (!shipForm.logisticsCompany.trim() || !shipForm.trackingNo.trim()) {
window.alert('请填写快递公司和运单号');
return;
}
setShippingId(shipModalId);
try {
await request('PARTNER_H5', `/partner/orders/${shipModalId}/manual-ship`, {
method: 'POST',
body: JSON.stringify(shipForm),
});
setShipModalId(null);
const next = await request<OrdersResponse>('PARTNER_H5', '/partner/orders');
setData({
list: Array.isArray(next.list) ? next.list : [],
hasWarehouseAccess: next.hasWarehouseAccess,
message: next.message,
});
} catch (err) {
window.alert(err instanceof Error ? err.message : '发货失败');
} finally {
setShippingId(null);
}
}
function canShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
if (loaded && !warehouseOk) {
return (
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-warehouse-denied" style={{ margin: 20, padding: 32, textAlign: 'center' }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 40 }}>warehouse</span>
<p className="headline-md" style={{ marginTop: 12 }}></p>
<p className="body-md text-muted" style={{ marginTop: 8, lineHeight: 1.6 }}>
</p>
</div>
</div>
);
}
return ( return (
<div className="page-no-tab partner-orders-page"> <div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
<PageHeader title="订单中心" onBack={() => navigate('/')} /> {!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-segment"> <div className="partner-segment">
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}></button> <button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}></button>
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button> {!tabRoot && (
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button>
)}
</div> </div>
{tab === 'orders' && ( {tab === 'orders' && (
@@ -82,50 +174,45 @@ export default function OrderListPage() {
{filtered.map((o) => { {filtered.map((o) => {
const st = orderStatusLabel(String(o.status)); const st = orderStatusLabel(String(o.status));
const payAmount = Number(o.payAmount || 0); const payAmount = Number(o.payAmount || 0);
const orderId = String(o.id);
return ( return (
<Link key={String(o.id)} to={`/orders/${o.id}`} className="partner-order-card"> <div key={orderId} className="partner-order-card-wrap">
<div className="partner-order-card-top"> <Link to={`/orders/${orderId}`} className="partner-order-card">
<span className="label-md text-muted">NO. {String(o.orderNo)}</span> <div className="partner-order-card-top">
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span> <span className="label-md text-muted">NO. {String(o.orderNo)}</span>
</div> <span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
<div className="partner-order-product">
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="material-symbols-outlined text-muted">liquor</span>
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div className="partner-order-product">
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3> <div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p> <span className="material-symbols-outlined text-muted">liquor</span>
<span className="partner-benefit-tag"> </div>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>confirmation_number</span> <div style={{ flex: 1, minWidth: 0 }}>
¥{Math.round(payAmount * 0.2)} <h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
</span> <p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
</div>
</div> </div>
</div> <div className="partner-order-address">
<div className="partner-order-address"> <span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span> <p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
</div>
<div className="partner-order-commission">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥{(payAmount * 0.04).toFixed(2)}</p>
</div> </div>
<div> </Link>
<p className="label-md text-muted"></p> {canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥0.00</p> <button
</div> type="button"
<div> className="partner-order-ship-btn"
<p className="label-md text-muted"></p> disabled={shippingId === orderId}
<p className="headline-md" style={{ marginTop: 4, color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</p> onClick={(e) => void handleShip(orderId, e)}
</div> >
</div>
</Link> </button>
)}
</div>
); );
})} })}
</> </>
)} )}
{tab === 'coupons' && ( {tab === 'coupons' && !tabRoot && (
<div style={{ padding: '0 20px' }}> <div style={{ padding: '0 20px' }}>
<div className="partner-revenue-card" style={{ marginBottom: 16 }}> <div className="partner-revenue-card" style={{ marginBottom: 16 }}>
<p className="partner-revenue-label"></p> <p className="partner-revenue-label"></p>
@@ -134,6 +221,50 @@ export default function OrderListPage() {
<p className="text-muted body-md text-center"> preV1 </p> <p className="text-muted body-md text-center"> preV1 </p>
</div> </div>
)} )}
{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> </div>
); );
} }
+7 -97
View File
@@ -1,102 +1,12 @@
import { useEffect, useState } from 'react'; import { getPartnerNavKind } from '../lib/partnerAccess';
import { PARTNER_STAFF_ROLE_LABELS, type PartnerStaffRole } from '@dukang/shared-types';
import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { toastError, toastSuccess } from '../lib/toast'; import CenterPage from './CenterPage';
/** 子账号个人中心:复用精简版 Center 布局 */
export default function PartnerMePage() { export default function PartnerMePage() {
const { account, refresh, logout } = usePartnerSession(); const { account } = usePartnerSession();
const [name, setName] = useState(account?.name ?? ''); const navKind = getPartnerNavKind(account);
const [saving, setSaving] = useState(false); const isWarehouse = navKind === 'warehouse_staff';
useEffect(() => { return <CenterPage variant="sub" roleLabel={isWarehouse ? '仓库管理员' : '拓店员'} />;
setName(account?.name ?? '');
}, [account?.name]);
const roleLabel = account?.staffRole
? PARTNER_STAFF_ROLE_LABELS[account.staffRole as PartnerStaffRole] || account.staffRole
: '拓店账号';
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
toastError('请输入姓名');
return;
}
if (trimmed === account?.name) {
toastSuccess('已保存');
return;
}
setSaving(true);
try {
await request('PARTNER_H5', '/partner/me', {
method: 'PUT',
body: JSON.stringify({ name: trimmed }),
});
await refresh();
toastSuccess('已保存');
} catch (e) {
toastError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
return (
<div className="page partner-me-page">
<section className="partner-profile-card" style={{ margin: '16px var(--space-page)' }}>
<div className="partner-profile-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ flex: 1 }}>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '我的'}</h2>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{roleLabel}</p>
{account?.companyName ? (
<p className="body-md text-muted" style={{ marginTop: 4 }}>{account.companyName}</p>
) : null}
</div>
</section>
<section className="partner-form-section" style={{ padding: '0 var(--space-page)' }}>
<label className="partner-form-label" htmlFor="me-name"></label>
<input
id="me-name"
className="partner-form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入姓名"
maxLength={32}
/>
<label className="partner-form-label" style={{ marginTop: 16 }}></label>
<input
className="partner-form-input"
value={account?.phone ?? ''}
readOnly
disabled
/>
<p className="label-md text-muted" style={{ marginTop: 4 }}></p>
</section>
<div style={{ padding: '24px var(--space-page) 100px' }}>
<button
type="button"
className="partner-btn-primary"
style={{ width: '100%' }}
disabled={saving}
onClick={() => void handleSave()}
>
{saving ? '保存中…' : '保存'}
</button>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={logout}
>
退
</button>
</div>
</div>
);
} }
+13 -4
View File
@@ -4,7 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing'; type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
function fmtMoney(n: number) { function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -15,6 +15,7 @@ function billStatusLabel(status: string) {
case 'DRAFT': return '待结算'; case 'DRAFT': return '待结算';
case 'CONFIRMED': return '审核中'; case 'CONFIRMED': return '审核中';
case 'PAID': return '已结算'; case 'PAID': return '已结算';
case 'REJECTED': return '已驳回';
default: return status; default: return status;
} }
} }
@@ -24,6 +25,7 @@ function billStatusClass(status: string) {
case 'DRAFT': return 'partner-settlement-status--pending'; case 'DRAFT': return 'partner-settlement-status--pending';
case 'CONFIRMED': return 'partner-settlement-status--reviewing'; case 'CONFIRMED': return 'partner-settlement-status--reviewing';
case 'PAID': return 'partner-settlement-status--settled'; case 'PAID': return 'partner-settlement-status--settled';
case 'REJECTED': return 'partner-settlement-status--rejected';
default: return ''; default: return '';
} }
} }
@@ -70,9 +72,10 @@ export default function SettlementPage() {
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month; const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
if (!matchMonth) return false; if (!matchMonth) return false;
if (statusFilter === 'all') return true; if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'DRAFT'; if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED';
if (statusFilter === 'settled') return b.status === 'PAID'; if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED'; if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true; return true;
}); });
}, [bills, month, statusFilter]); }, [bills, month, statusFilter]);
@@ -93,8 +96,9 @@ export default function SettlementPage() {
const statusTabs: Array<{ key: StatusFilter; label: string }> = [ const statusTabs: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: '全部' }, { key: 'all', label: '全部' },
{ key: 'pending', label: '待结算' }, { key: 'pending', label: '待结算' },
{ key: 'settled', label: '已结算' },
{ key: 'reviewing', label: '审核中' }, { key: 'reviewing', label: '审核中' },
{ key: 'rejected', label: '已驳回' },
{ key: 'settled', label: '已结算' },
]; ];
return ( return (
@@ -188,7 +192,12 @@ export default function SettlementPage() {
<span className="partner-settlement-dot" /> <span className="partner-settlement-dot" />
¥{fmtMoney(Number(bill.orderCommission || 0))} ¥{fmtMoney(Number(bill.orderCommission || 0))}
</p> </p>
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' ? ' text-primary' : ''}`}> {bill.status === 'REJECTED' && bill.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 6 }}>
{bill.rejectReason}
</p>
) : null}
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))} ¥{fmtMoney(Number(bill.totalAmount || 0))}
</p> </p>
</div> </div>
@@ -20,7 +20,6 @@ import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { isWechatEnv, weixinSdk } from '../lib/weixin'; import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { import {
@@ -82,8 +81,6 @@ export default function StoreCreatePage() {
const { account, refresh } = usePartnerSession(); const { account, refresh } = usePartnerSession();
const subAccountLayout = isSubAccount(account);
const accountId = account?.id; const accountId = account?.id;
const wechatReady = !!account?.hasWechat; const wechatReady = !!account?.hasWechat;
@@ -526,7 +523,9 @@ export default function StoreCreatePage() {
try { try {
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean); const envPhotoUrls = Array.from(
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
).slice(0, 3);
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', { const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
@@ -610,7 +609,7 @@ export default function StoreCreatePage() {
return ( return (
<div className={`partner-page-sticky${subAccountLayout ? ' partner-page-sticky--tabbar' : ''}`}> <div className="partner-page-sticky">
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} /> <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 && ( {step > 1 && (
+139 -20
View File
@@ -5,7 +5,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast'; import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess'; import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField';
import { import {
canPartnerOpenStore, canPartnerOpenStore,
storeAuditLabel, storeAuditLabel,
@@ -16,19 +18,36 @@ import {
} from '../lib/storeStatus'; } from '../lib/storeStatus';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED']; const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
const ENV_SLOT_COUNT = 3;
function uniqueEnvUrls(urls: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of urls) {
const url = raw.trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
export default function StoreDetailPage() { export default function StoreDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { account } = usePartnerSession(); const { account } = usePartnerSession();
const subReadonly = isSubAccount(account); const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null); const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' }); const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
const [coverUrl, setCoverUrl] = useState('');
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN'); const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false); const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [actionError, setActionError] = useState(''); const [actionError, setActionError] = useState('');
const [wechatReady, setWechatReady] = useState(false);
function applyStore(data: Record<string, unknown>) { function applyStore(data: Record<string, unknown>) {
setStore(data); setStore(data);
@@ -39,6 +58,15 @@ export default function StoreDetailPage() {
intro: String(data.intro || ''), intro: String(data.intro || ''),
}); });
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue); setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
setCoverUrl(String(data.coverUrl || ''));
const envFromMedia = Array.isArray(data.media)
? uniqueEnvUrls(
(data.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || '')),
)
: [];
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
} }
useEffect(() => { useEffect(() => {
@@ -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) { if (loadError) {
return ( return (
<div className="partner-detail-page"> <div className="partner-detail-page">
@@ -124,13 +188,17 @@ export default function StoreDetailPage() {
if (!store) return <div className="empty">...</div>; if (!store) return <div className="empty">...</div>;
const envPhotos = Array.isArray(store.media) const envPhotos = uniqueEnvUrls(
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV') Array.isArray(store.media)
: []; ? (store.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || ''))
: [],
);
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase(); const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
const auditPending = auditStatus === 'PENDING'; const auditPending = auditStatus === 'PENDING';
const auditRejected = auditStatus === 'REJECTED'; const auditRejected = auditStatus === 'REJECTED';
const readOnly = subReadonly || status === 'CLOSED' || auditPending; const readOnly = !canMutate || status === 'CLOSED' || auditPending;
const canOpen = canPartnerOpenStore(auditStatus); const canOpen = canPartnerOpenStore(auditStatus);
return ( return (
@@ -170,7 +238,7 @@ export default function StoreDetailPage() {
)} )}
</section> </section>
{!subReadonly && ( {canMutate && (
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3> <h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
@@ -200,13 +268,29 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}></h3> <h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}></h3>
<div className="partner-cover"> {!canMutate || readOnly ? (
<AppImage <div className="partner-cover">
src={store.coverUrl ? String(store.coverUrl) : null} <AppImage
alt={form.name} src={coverUrl || null}
wrapperClassName="app-image--fill" alt={form.name}
/> wrapperClassName="app-image--fill"
</div> />
</div>
) : (
<div style={{ marginBottom: 16 }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<OssUploadField
wide
bizType="STORE_TITLE"
mediaType="IMAGE"
value={coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={setCoverUrl}
label="点击更换门头照"
/>
</div>
)}
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} /> <input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
@@ -234,13 +318,48 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}></h3> <h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}></h3>
<span className="label-md text-muted">{envPhotos.length ? `已上传 ${envPhotos.length}` : '暂无照片'}</span> <span className="label-md text-muted">
{canMutate && !readOnly
? `${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length}`
: envPhotos.length
? `已上传 ${envPhotos.length}`
: '暂无照片'}
</span>
</div> </div>
{envPhotos.length > 0 ? ( {canMutate && !readOnly ? (
<>
<div className="partner-upload-grid">
{envPhotoUrls.map((url, index) => (
<OssUploadField
key={index}
compact
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
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"> <div className="partner-photo-grid">
{envPhotos.map((photo, index) => ( {envPhotos.map((url, index) => (
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}> <div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" /> <AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
</div> </div>
))} ))}
</div> </div>
@@ -264,7 +383,7 @@ export default function StoreDetailPage() {
<footer className="partner-save-footer"> <footer className="partner-save-footer">
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button> <button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{!subReadonly && ( {canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}> <button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span> <span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'} {saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
+15 -15
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess'; import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
import { import {
canPartnerOpenStore, canPartnerOpenStore,
storeAuditLabel, storeAuditLabel,
@@ -25,11 +25,14 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
export default function StoreListPage() { export default function StoreListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession(); const { account } = usePartnerSession();
const readonly = isSubAccount(account); const canMutate = canManagePartnerStore(account);
const canCreate = canCreatePartnerStore(account);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]); const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [filter, setFilter] = useState<StatusFilter>('ALL'); const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
const [filter, setFilter] = useState<StatusFilter>(initialFilter);
const [updatingId, setUpdatingId] = useState<string | null>(null); const [updatingId, setUpdatingId] = useState<string | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -42,6 +45,10 @@ export default function StoreListPage() {
void loadStores(); void loadStores();
}, [navigate, loadStores]); }, [navigate, loadStores]);
useEffect(() => {
document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => { const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q); const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
@@ -78,16 +85,7 @@ export default function StoreListPage() {
} }
return ( return (
<div className="page partner-store-page"> <div className="page partner-store-page partner-home--flush-top">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<div className="partner-page-title-block">
<h2>{readonly ? '我的门店' : '门店管理'}</h2>
<p className="text-muted body-md">{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}</p>
</div>
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>} {error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
<div className="partner-sticky-filter"> <div className="partner-sticky-filter">
@@ -104,12 +102,14 @@ export default function StoreListPage() {
</div> </div>
</div> </div>
{canCreate && (
<Link to="/stores/new" className="partner-fab-link"> <Link to="/stores/new" className="partner-fab-link">
<button type="button" className="partner-btn-primary"> <button type="button" className="partner-btn-primary">
<span className="material-symbols-outlined">add_business</span> <span className="material-symbols-outlined">add_business</span>
</button> </button>
</Link> </Link>
)}
{filtered.length === 0 && <div className="empty"></div>} {filtered.length === 0 && <div className="empty"></div>}
@@ -149,7 +149,7 @@ export default function StoreListPage() {
</div> </div>
</div> </div>
</Link> </Link>
{!readonly && ( {canMutate && (
<div className="partner-store-card-actions"> <div className="partner-store-card-actions">
<button <button
type="button" type="button"
+415 -35
View File
@@ -13,13 +13,23 @@ html {
/* ── Partner auth ── */ /* ── Partner auth ── */
.partner-auth-page { .partner-auth-page {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
padding: 48px var(--space-page) var(--space-page); padding: 48px var(--space-page) calc(24px + env(safe-area-inset-bottom, 0px));
background-color: var(--color-background); background-color: var(--color-background);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png"); background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
box-sizing: border-box;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
@media (min-height: 720px) {
.partner-auth-page {
justify-content: center;
}
} }
.partner-auth-page--quick { .partner-auth-page--quick {
@@ -166,14 +176,27 @@ html {
.partner-checkbox-row { .partner-checkbox-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 12px; gap: 12px;
margin-top: var(--space-md); margin-top: var(--space-md);
font-size: 12px; font-size: 12px;
line-height: 1.5;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
} }
.partner-checkbox-row input { margin-top: 0; accent-color: var(--color-heritage-red); } .partner-checkbox-row input {
margin-top: 2px;
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-heritage-red);
}
.partner-checkbox-row--agreement {
margin-top: 8px;
margin-bottom: 4px;
padding: 8px 0;
}
.partner-auth-footer { .partner-auth-footer {
margin-top: var(--space-lg); margin-top: var(--space-lg);
@@ -621,9 +644,6 @@ html {
.partner-expansion-split { .partner-expansion-split {
display: flex; display: flex;
border-bottom: 1px solid rgba(226, 190, 188, 0.2);
padding-bottom: var(--space-md);
margin-bottom: var(--space-md);
} }
.partner-expansion-split > div { .partner-expansion-split > div {
@@ -635,6 +655,28 @@ html {
border-left: 1px solid rgba(226, 190, 188, 0.2); border-left: 1px solid rgba(226, 190, 188, 0.2);
} }
.partner-expansion-value-row {
display: flex;
align-items: center;
gap: 8px;
}
.partner-expansion-trend {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 600;
}
.partner-expansion-trend--up {
color: #16a34a;
}
.partner-expansion-trend--down {
color: var(--color-heritage-red);
}
.partner-form-error { .partner-form-error {
margin: 0 20px 12px; margin: 0 20px 12px;
padding: 10px 12px; padding: 10px 12px;
@@ -845,6 +887,57 @@ html {
color: #fff; color: #fff;
} }
.partner-leaderboard-preview .partner-leaderboard-header {
margin-bottom: 12px;
}
.partner-leaderboard-avatar {
position: relative;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-surface-container-low);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.partner-leaderboard-avatar .material-symbols-outlined {
font-size: 22px;
color: var(--color-subtle-gray);
font-variation-settings: 'FILL' 1;
}
.partner-leaderboard-avatar-badge {
position: absolute;
left: -2px;
top: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #9ca3af;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
box-shadow: 0 0 0 2px #fff;
}
.partner-leaderboard-avatar--rank-1 .partner-leaderboard-avatar-badge {
background: #d4a017;
}
.partner-leaderboard-avatar--rank-2 .partner-leaderboard-avatar-badge {
background: #9ca3af;
}
.partner-leaderboard-avatar--rank-3 .partner-leaderboard-avatar-badge {
background: #cd7f32;
}
.partner-leaderboard-info { .partner-leaderboard-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -1296,7 +1389,7 @@ nav.app-tabbar .app-tabbar-label {
.partner-sticky-footer--above-tabbar { .partner-sticky-footer--above-tabbar {
bottom: calc(56px + env(safe-area-inset-bottom, 0px)); bottom: calc(56px + env(safe-area-inset-bottom, 0px));
z-index: 90; z-index: 110;
} }
.partner-stepper { .partner-stepper {
@@ -1752,12 +1845,13 @@ nav.app-tabbar .app-tabbar-label {
max-width: 480px; max-width: 480px;
margin: 0 auto; margin: 0 auto;
transform: none; transform: none;
background: rgba(250, 249, 247, 0.92); background: rgba(250, 249, 247, 0.96);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
border-top: 1px solid rgba(226, 190, 188, 0.2); border-top: 1px solid rgba(226, 190, 188, 0.2);
box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05); box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05);
padding: 16px var(--space-page) calc(16px + env(safe-area-inset-bottom, 0px)); padding: 16px var(--space-page) calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 60; /* 高于 .app-tabbar(100),防止偶发叠层遮挡「下一步/提交」 */
z-index: 110;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1801,7 +1895,7 @@ nav.app-tabbar .app-tabbar-label {
.partner-sticky-filter { .partner-sticky-filter {
position: sticky; position: sticky;
top: 56px; top: 0;
z-index: 40; z-index: 40;
background: rgba(250, 249, 247, 0.95); background: rgba(250, 249, 247, 0.95);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
@@ -2401,6 +2495,14 @@ nav.app-tabbar .app-tabbar-label {
justify-content: center; justify-content: center;
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
padding: 0;
cursor: pointer;
overflow: hidden;
}
.partner-profile-avatar:disabled {
opacity: 0.7;
cursor: wait;
} }
.partner-profile-avatar .material-symbols-outlined { .partner-profile-avatar .material-symbols-outlined {
@@ -2409,22 +2511,81 @@ nav.app-tabbar .app-tabbar-label {
font-variation-settings: 'FILL' 1; font-variation-settings: 'FILL' 1;
} }
.partner-profile-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.partner-profile-avatar-hint {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 2px 0;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
text-align: center;
color: #fff;
background: rgba(166, 29, 36, 0.82);
}
.partner-profile-main {
flex: 1;
min-width: 0;
}
.partner-profile-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.partner-profile-name {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.3;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.partner-profile-location {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
color: var(--color-subtle-gray);
}
.partner-role-badge { .partner-role-badge {
flex-shrink: 0;
background: var(--color-secondary-container); background: var(--color-secondary-container);
color: var(--color-on-secondary-container); color: var(--color-on-secondary-container);
padding: 4px 8px; padding: 4px 8px;
border-radius: 8px; border-radius: 8px;
font-family: var(--font-label); font-family: var(--font-label);
font-size: 10px; font-size: 10px;
text-transform: uppercase; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.02em;
white-space: nowrap;
line-height: 1.2;
}
.partner-assets-section {
margin-top: var(--space-lg);
} }
.partner-bills-banner { .partner-bills-banner {
margin: 0 var(--space-page) 12px; margin: 0 var(--space-page) 12px;
padding: 16px; padding: 16px;
background: rgba(166, 29, 36, 0.05); background: rgba(166, 29, 36, 0.06);
border: 1px solid rgba(166, 29, 36, 0.1); border: 1px solid rgba(166, 29, 36, 0.12);
border-radius: var(--radius-md); border-radius: var(--radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2463,13 +2624,100 @@ nav.app-tabbar .app-tabbar-label {
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
border: 1px solid rgba(166, 29, 36, 0.05); border: 1px solid rgba(166, 29, 36, 0.05);
text-decoration: none;
color: inherit;
display: block;
} }
.partner-finance-card--hero { .partner-finance-card--hero {
grid-column: span 2; grid-column: 1 / -1;
background: var(--color-heritage-red); background: linear-gradient(135deg, #a61d24 0%, #820012 100%);
color: #fff; color: #fff;
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25); box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25);
border: none;
padding: 20px 24px;
}
.partner-finance-hero-label {
font-size: 13px;
opacity: 0.88;
margin: 0 0 8px;
}
.partner-finance-hero-amount {
font-size: 32px;
font-weight: 700;
letter-spacing: 0.02em;
margin: 0;
line-height: 1.2;
}
.partner-finance-value {
margin-top: 8px;
font-size: 18px;
font-weight: 700;
color: var(--color-on-surface);
}
.partner-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 var(--space-page) 8px;
}
.partner-menu-section .partner-section-head {
margin: 0 0 8px;
}
.partner-menu-item-right {
display: flex;
align-items: center;
gap: 6px;
}
.partner-menu-badge {
font-size: 12px;
font-weight: 600;
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.08);
padding: 2px 8px;
border-radius: 999px;
}
.partner-menu-status {
font-size: 12px;
color: #2563eb;
font-weight: 500;
}
.partner-logout-btn {
width: calc(100% - 2 * var(--space-page));
margin: var(--space-lg) var(--space-page) 0;
padding: 16px;
border: 1px solid rgba(166, 29, 36, 0.08);
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: var(--shadow-card);
color: var(--color-heritage-red);
font-family: var(--font-headline);
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.partner-logout-btn .material-symbols-outlined {
color: var(--color-heritage-red);
}
.partner-center-footer {
text-align: center;
opacity: 0.35;
padding: 24px 0 8px;
} }
.partner-menu-section { .partner-menu-section {
@@ -2488,14 +2736,28 @@ nav.app-tabbar .app-tabbar-label {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%;
padding: 16px; padding: 16px;
border: none;
border-bottom: 1px solid var(--color-surface-container); border-bottom: 1px solid var(--color-surface-container);
border-radius: 0;
background: transparent;
color: inherit; color: inherit;
font: inherit;
text-align: left;
text-decoration: none; text-decoration: none;
cursor: pointer;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
} }
.partner-menu-item:last-child { border-bottom: none; } .partner-menu-item:last-child { border-bottom: none; }
.partner-menu-item:active {
background: rgba(166, 29, 36, 0.04);
}
.partner-menu-item-left { .partner-menu-item-left {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2513,24 +2775,6 @@ nav.app-tabbar .app-tabbar-label {
color: var(--color-heritage-red); color: var(--color-heritage-red);
} }
.partner-logout-btn {
width: calc(100% - 40px);
margin: var(--space-lg) var(--space-page);
padding: 16px;
border: none;
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: var(--shadow-card);
color: var(--color-error);
font-size: 16px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
/* ── Settlement page ── */ /* ── Settlement page ── */
.partner-settlement-page { .partner-settlement-page {
min-height: 100vh; min-height: 100vh;
@@ -2778,6 +3022,11 @@ nav.app-tabbar .app-tabbar-label {
color: var(--color-success-green); color: var(--color-success-green);
} }
.partner-settlement-status--rejected {
background: rgba(166, 29, 36, 0.12);
color: var(--color-heritage-red);
}
.partner-settlement-item-meta { .partner-settlement-item-meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3065,3 +3314,134 @@ body {
padding-top: 12px; padding-top: 12px;
border-top: 1px dashed rgba(166, 29, 36, 0.15); border-top: 1px dashed rgba(166, 29, 36, 0.15);
} }
.partner-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;
}
+5 -2
View File
@@ -35,8 +35,11 @@
.legal-h5-title { .legal-h5-title {
margin: 0; margin: 0;
font-size: 17px; /* 与微信系统标题重复,仅保留顶栏高度与返回 */
font-weight: 600; visibility: hidden;
font-size: 0;
line-height: 0;
color: transparent;
} }
.legal-h5-body { .legal-h5-body {
+25 -2
View File
@@ -9,6 +9,8 @@ export type ShopAccountProfile = {
storeId: string; storeId: string;
name: string; name: string;
phone: string; phone: string;
/** 是否已绑定微信(来自 /shop/auth/me account.hasWechat */
hasWechat?: boolean;
wxOpenId?: string | null; wxOpenId?: string | null;
store?: { name: string }; store?: { name: string };
}; };
@@ -18,7 +20,27 @@ export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
} }
export async function fetchShopAccount(): Promise<ShopAccountProfile> { export async function fetchShopAccount(): Promise<ShopAccountProfile> {
return request<ShopAccountProfile>('SHOP_H5', '/shop/auth/me'); const me = await request<{
id?: string;
storeId?: string;
name?: string;
phone?: string;
account?: {
id?: string;
name?: string;
phone?: string;
hasWechat?: boolean;
};
store?: { name?: string } | null;
}>('SHOP_H5', '/shop/auth/me');
return {
id: String(me.account?.id ?? me.id ?? ''),
storeId: String(me.storeId ?? ''),
name: String(me.account?.name ?? me.name ?? ''),
phone: String(me.account?.phone ?? me.phone ?? ''),
hasWechat: !!me.account?.hasWechat,
store: me.store?.name ? { name: String(me.store.name) } : undefined,
};
} }
export function needsWechatAuth( export function needsWechatAuth(
@@ -26,7 +48,8 @@ export function needsWechatAuth(
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null, config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
): boolean { ): boolean {
if (config && !isWxAuthorizeEnabled(config)) return false; if (config && !isWxAuthorizeEnabled(config)) return false;
return isWechatEnv() && !!profile && !profile.wxOpenId; const unbound = !!profile && !(profile.hasWechat || profile.wxOpenId);
return isWechatEnv() && unbound;
} }
export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<boolean> { export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<boolean> {
+44 -19
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 { Link, useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
@@ -28,6 +28,36 @@ function formatWechatError(e: unknown): string {
return text; 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() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession } = useStoreSession(); const { applySession } = useStoreSession();
@@ -42,6 +72,7 @@ export default function LoginPage() {
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false); const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => { useEffect(() => {
fetchClientConfig() fetchClientConfig()
@@ -71,6 +102,7 @@ export default function LoginPage() {
function ensureAgreed() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先阅读并同意用户协议'); setMsg('请先阅读并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false; return false;
} }
return true; return true;
@@ -180,6 +212,11 @@ export default function LoginPage() {
<div className="shop-quick-actions"> <div className="shop-quick-actions">
{msg && <p className="shop-login-msg">{msg}</p>} {msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
{canWechatQuick ? ( {canWechatQuick ? (
<button <button
type="button" type="button"
@@ -270,6 +307,12 @@ export default function LoginPage() {
{msg && <p className="shop-login-msg">{msg}</p>} {msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
<button <button
type="button" type="button"
className="shop-login-submit" className="shop-login-submit"
@@ -300,24 +343,6 @@ export default function LoginPage() {
</> </>
)} )}
</div> </div>
<label className="shop-login-agreement">
<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>
</main> </main>
<footer className="shop-login-footer"> <footer className="shop-login-footer">
+90 -2
View File
@@ -1,21 +1,95 @@
import { useEffect, useState } from 'react'; 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 { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api'; import { getStoreProfile, request } from '../lib/api';
import {
authorizeShopWechat,
fetchClientConfig,
fetchShopAccount,
handleShopWechatCallback,
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
export default function MinePage() { export default function MinePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { resetSession, store: sessionStore } = useStoreSession(); const [searchParams, setSearchParams] = useSearchParams();
const { resetSession, store: sessionStore, applySession } = useStoreSession();
const profile = sessionStore ?? getStoreProfile(); const profile = sessionStore ?? getStoreProfile();
const [store, setStore] = useState<Record<string, unknown> | null>(null); const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [hasWechat, setHasWechat] = useState<boolean | null>(null);
const [wxAuthorize, setWxAuthorize] = useState(false);
const [binding, setBinding] = useState(false);
const [bindMsg, setBindMsg] = useState('');
useEffect(() => { useEffect(() => {
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)); 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 openTime = String(store?.openTime || '09:30');
const closeTime = String(store?.closeTime || '22:00'); const closeTime = String(store?.closeTime || '22:00');
const multiStore = (profile?.stores?.length ?? 0) > 1; const multiStore = (profile?.stores?.length ?? 0) > 1;
const showBindWechat = wxAuthorize && hasWechat === false;
async function bindWechat() {
if (binding) return;
setBindMsg('');
if (!isWechatEnv()) {
setBindMsg('请在微信内打开门店端以绑定微信');
return;
}
setBinding(true);
try {
const result = await authorizeShopWechat();
if (!result) {
// 跳转公众号 OAuth,回跳后由上面 useEffect 处理
return;
}
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
setHasWechat(true);
setBindMsg('微信绑定成功');
} else {
setBindMsg('绑定未完成,请重试');
}
} catch (e) {
setBindMsg(e instanceof Error ? e.message : '微信绑定失败');
} finally {
setBinding(false);
}
}
return ( return (
<div className="shop-mine-page"> <div className="shop-mine-page">
@@ -77,6 +151,20 @@ export default function MinePage() {
<p></p> <p></p>
</div> </div>
{bindMsg ? <p className="shop-mine-bind-msg">{bindMsg}</p> : null}
{showBindWechat ? (
<button
type="button"
className="shop-mine-bind-wechat"
disabled={binding}
onClick={() => void bindWechat()}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>chat</span>
{binding ? '绑定中…' : '绑定微信'}
</button>
) : null}
<button <button
type="button" type="button"
className="shop-mine-logout" className="shop-mine-logout"
+74 -22
View File
@@ -188,7 +188,7 @@
.shop-login-submit { .shop-login-submit {
width: 100%; width: 100%;
margin-top: 32px; margin-top: 16px;
padding: 16px; padding: 16px;
border: none; border: none;
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -248,14 +248,18 @@
.shop-login-agreement { .shop-login-agreement {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 8px; gap: 8px;
max-width: 280px; max-width: 100%;
margin: 32px auto 0; margin: 16px 0 0;
padding: 8px 0;
} }
.shop-login-agreement input { .shop-login-agreement input {
margin-top: 0; margin-top: 2px;
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-heritage-red); accent-color: var(--color-heritage-red);
} }
@@ -455,6 +459,15 @@
margin-top: auto; 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 { .shop-quick-login-btn {
width: 100%; width: 100%;
height: 56px; height: 56px;
@@ -1489,25 +1502,13 @@
} }
.shop-records-header { .shop-records-header {
position: fixed; /* 页内标题已隐藏:不再占位;保留类名以免改 JSX */
top: 0; display: none !important;
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;
} }
.shop-records-filters { .shop-records-filters {
position: sticky; position: sticky;
top: 56px; top: 0;
z-index: 40; z-index: 40;
background: var(--color-surface); background: var(--color-surface);
border-bottom: 1px solid var(--color-surface-container-highest); border-bottom: 1px solid var(--color-surface-container-highest);
@@ -1578,7 +1579,7 @@
} }
.shop-records-main { .shop-records-main {
padding-top: 56px; padding-top: 0;
} }
.shop-records-summary { .shop-records-summary {
@@ -2153,8 +2154,38 @@
color: var(--color-subtle-gray); 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 { .shop-mine-logout {
margin-top: 24px; margin-top: 12px;
width: 100%; width: 100%;
height: 48px; height: 48px;
border: 1px solid var(--color-heritage-red); border: 1px solid var(--color-heritage-red);
@@ -2702,3 +2733,24 @@
cursor: pointer; 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;
}
+5 -2
View File
@@ -35,8 +35,11 @@
.legal-h5-title { .legal-h5-title {
margin: 0; margin: 0;
font-size: 17px; /* 与微信系统标题重复,仅保留顶栏高度与返回 */
font-weight: 600; visibility: hidden;
font-size: 0;
line-height: 0;
color: transparent;
} }
.legal-h5-body { .legal-h5-body {
@@ -5,11 +5,10 @@ type SubPageHeaderProps = {
export default function SubPageHeader({ title, onBack }: SubPageHeaderProps) { export default function SubPageHeader({ title, onBack }: SubPageHeaderProps) {
return ( 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}> <button type="button" className="sub-page-header-back" aria-label="返回" onClick={onBack}>
<span className="material-symbols-outlined">arrow_back</span> <span className="material-symbols-outlined">arrow_back</span>
</button> </button>
<h1 className="app-page-title">{title}</h1>
</header> </header>
); );
} }
@@ -7,9 +7,13 @@ type TabMainHeaderProps = {
}; };
export default function TabMainHeader({ title, extra, className = '' }: TabMainHeaderProps) { export default function TabMainHeader({ title, extra, className = '' }: TabMainHeaderProps) {
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
if (!extra) {
return null;
}
return ( return (
<header className={`tab-main-header${className ? ` ${className}` : ''}`}> <header className={`tab-main-header${className ? ` ${className}` : ''}`} aria-label={title}>
<h1 className="app-page-title">{title}</h1>
{extra ? <div className="tab-main-header-extra">{extra}</div> : null} {extra ? <div className="tab-main-header-extra">{extra}</div> : null}
</header> </header>
); );
+20 -21
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import type { WechatLoginResult } from '@dukang/shared-types'; import type { WechatLoginResult } from '@dukang/shared-types';
@@ -31,6 +31,7 @@ export default function LoginPage() {
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null); const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [bindMode, setBindMode] = useState(false); const [bindMode, setBindMode] = useState(false);
const [wxAuthorize, setWxAuthorize] = useState(false); const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } = const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
useSmsCode(); useSmsCode();
@@ -73,6 +74,7 @@ export default function LoginPage() {
function ensureAgreed() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先勾选并同意用户协议'); setMsg('请先勾选并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false; return false;
} }
return true; return true;
@@ -194,6 +196,23 @@ export default function LoginPage() {
{displayMsg || sentHint} {displayMsg || sentHint}
</p> </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 <button
type="button" type="button"
className="login-sms-btn" className="login-sms-btn"
@@ -219,26 +238,6 @@ export default function LoginPage() {
</> </>
)} )}
</main> </main>
<footer className="login-footer">
<label className="login-agreement">
<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>
</footer>
</div> </div>
); );
} }
+66 -3
View File
@@ -18,6 +18,15 @@ type OrderItem = {
type OrderDelivery = { type OrderDelivery = {
provider?: string; provider?: string;
trackingNo?: string;
logisticsCompany?: string;
manualQueryUrl?: string;
providerOrderNo?: string;
};
type TrackNode = {
trackInfo?: string;
createdAt?: string;
}; };
type OrderPayment = { type OrderPayment = {
@@ -101,8 +110,10 @@ function progressActiveIndex(status: string) {
} }
} }
function deliveryProviderLabel(provider?: string) { function deliveryProviderLabel(provider?: string, company?: string) {
if (!provider || provider === 'MOCK' || provider === 'XIAOFEIXIA') return '小飞侠配送'; if (company) return company;
if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') return '小飞侠配送';
if (provider === 'LOGISTICS') return '快递配送';
return provider; return provider;
} }
@@ -124,13 +135,27 @@ export default function OrderDetailPage() {
const [copyHint, setCopyHint] = useState(''); const [copyHint, setCopyHint] = useState('');
const [shareToast, setShareToast] = useState(''); const [shareToast, setShareToast] = useState('');
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
const isReship = order?.orderType === 'RESHIPMENT'; 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() { async function loadOrder() {
if (!id) return; if (!id) return;
const data = await request<Order>('USER_H5', `/trade/orders/${id}`); const data = await request<Order>('USER_H5', `/trade/orders/${id}`);
setOrder(data); setOrder(data);
if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') {
void loadTrack();
}
} }
useEffect(() => { useEffect(() => {
@@ -353,11 +378,49 @@ export default function OrderDetailPage() {
</div> </div>
<div className="order-detail-kv"> <div className="order-detail-kv">
<span></span> <span></span>
<span>{deliveryProviderLabel(order.delivery?.provider)}</span> <span>
{deliveryProviderLabel(order.delivery?.provider, order.delivery?.logisticsCompany)}
</span>
</div> </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> </div>
</section> </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 && ( {!isReship && (
<section className="order-detail-card"> <section className="order-detail-card">
<h3 className="order-detail-card-title"> <h3 className="order-detail-card-title">
+26 -3
View File
@@ -917,6 +917,11 @@
z-index: 40; z-index: 40;
} }
/* 首页有城市栏顶栏时 top=56;无额外内容已不渲染顶栏时贴顶 */
.home-page:not(:has(.tab-main-header)) .home-aroma-nav {
top: 0;
}
.home-aroma-tab { .home-aroma-tab {
border: none; border: none;
background: none; background: none;
@@ -1553,8 +1558,9 @@
.login-agreement { .login-agreement {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 8px; gap: 8px;
margin: 12px 0 16px;
font-family: var(--font-label); font-family: var(--font-label);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -1566,8 +1572,8 @@
.login-agreement input { .login-agreement input {
margin-top: 0; margin-top: 0;
width: 16px; width: 18px;
height: 16px; height: 18px;
flex-shrink: 0; flex-shrink: 0;
accent-color: var(--color-heritage-red); accent-color: var(--color-heritage-red);
} }
@@ -5926,3 +5932,20 @@
opacity: 0.5; 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;
}
+5
View File
@@ -177,6 +177,11 @@
color: var(--hq-muted); color: var(--hq-muted);
} }
.login-agreement {
margin: 8px 0 12px;
padding: 4px 0;
}
.login-remember { .login-remember {
align-items: center; align-items: center;
padding: 0 4px; padding: 0 4px;
+18 -18
View File
@@ -212,24 +212,6 @@ export default function LoginPage() {
<Text></Text> <Text></Text>
</View> </View>
<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>
<View
className={`login-wechat-btn${wxLoading ? ' is-disabled' : ''}`}
onClick={wxLoading ? undefined : wechatLogin}
>
<WechatIcon />
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
</View>
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}> <View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} /> <View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
<Text className="login-agreement-text"> <Text className="login-agreement-text">
@@ -255,6 +237,24 @@ export default function LoginPage() {
</Text> </Text>
</Text> </Text>
</View> </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>
<View className="login-divider">
<Text></Text>
</View>
<View
className={`login-wechat-btn${wxLoading ? ' is-disabled' : ''}`}
onClick={wxLoading ? undefined : wechatLogin}
>
<WechatIcon />
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
</View>
</View> </View>
<View className="login-footer"> <View className="login-footer">
+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` | | **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` |
### 微信登录 `invalid code` ### 微信登录 `invalid code`
@@ -30,17 +30,25 @@ pnpm --filter @dukang/mini-user dev:weapp
| 小程序 appid | `project.config.json``wxda31c8e8e85051e7` | | 小程序 appid | `project.config.json``wxda31c8e8e85051e7` |
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) | | 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
**本地联调(不接真实微信)**API 指到本机且开启 Mock **本地联调(不接真实微信)**保持默认即可(API → `localhost:3000`),并开启 Mock
```bash ```bash
# 终端 1 # 终端 1
pnpm dev:api # server/.env 保持 MOCK_WECHAT=true pnpm dev:api # server/.env 保持 MOCK_SMS=true / MOCK_WECHAT=true
# 终端 2 # 终端 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 页) ## 页面结构(18 页)
+4 -2
View File
@@ -2,8 +2,10 @@ import { createRequire } from 'node:module';
import path from 'node:path'; import path from 'node:path';
import { defineConfig } from '@tarojs/cli'; import { defineConfig } from '@tarojs/cli';
/** 小程序/H5 请求的后端 origin(不含 /api);本地联调可用 VITE_API_TARGET 覆盖 */ /** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
const API_ORIGIN = process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top'; 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')); const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
+6 -2
View File
@@ -10,6 +10,8 @@ type PageNavBarProps = {
right?: ReactNode; right?: ReactNode;
}; };
const isH5 = process.env.TARO_ENV === 'h5';
/** 内页自定义导航栏(返回 + 标题 + 右侧操作),适配刘海屏 */ /** 内页自定义导航栏(返回 + 标题 + 右侧操作),适配刘海屏 */
export default function PageNavBar({ export default function PageNavBar({
title, title,
@@ -19,16 +21,18 @@ export default function PageNavBar({
right, right,
}: PageNavBarProps) { }: PageNavBarProps) {
const metrics = useNavBarMetrics(); const metrics = useNavBarMetrics();
const showTitle = !isH5 && titleVisible;
return ( return (
<View <View
className={`page-nav-bar${solid ? ' page-nav-bar--solid' : ''}`} className={`page-nav-bar${solid ? ' page-nav-bar--solid' : ''}`}
style={subPageNavBarStyle(metrics)} style={subPageNavBarStyle(metrics)}
aria-label={title}
> >
<Text <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> </Text>
<View <View
className="page-nav-bar__content" className="page-nav-bar__content"
@@ -9,6 +9,8 @@ type SubPageHeaderProps = {
right?: ReactNode; right?: ReactNode;
}; };
const isH5 = process.env.TARO_ENV === 'h5';
/** 子页面固定实底顶栏(确认订单 / 地址 / 支付 / 订单列表等) */ /** 子页面固定实底顶栏(确认订单 / 地址 / 支付 / 订单列表等) */
export default function SubPageHeader({ title, onBack, right }: SubPageHeaderProps) { export default function SubPageHeader({ title, onBack, right }: SubPageHeaderProps) {
const metrics = useNavBarMetrics(); const metrics = useNavBarMetrics();
@@ -27,8 +29,8 @@ export default function SubPageHeader({ title, onBack, right }: SubPageHeaderPro
} }
return ( return (
<View className="sub-page-header" style={subPageNavBarStyle(metrics)}> <View className="sub-page-header" style={subPageNavBarStyle(metrics)} aria-label={title}>
<Text className="sub-page-header__title">{title}</Text> {!isH5 ? <Text className="sub-page-header__title">{title}</Text> : null}
<View <View
className="sub-page-header__content" className="sub-page-header__content"
style={subPageNavContentStyle(metrics)} style={subPageNavContentStyle(metrics)}
@@ -7,13 +7,20 @@ type TabMainHeaderProps = {
extra?: ReactNode; extra?: ReactNode;
}; };
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让 */ const isH5 = process.env.TARO_ENV === 'h5';
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让;H5 不展示标题(与微信系统标题重复) */
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) { export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
const metrics = useNavBarMetrics(); const metrics = useNavBarMetrics();
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
if (isH5 && !extra) {
return null;
}
return ( return (
<View className="tab-main-header" style={navBarStyle(metrics)}> <View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
<Text className="tab-main-header__title">{title}</Text> {!isH5 ? <Text className="tab-main-header__title">{title}</Text> : null}
<View <View
className="tab-main-header__content" className="tab-main-header__content"
style={tabNavContentStyle(metrics)} style={tabNavContentStyle(metrics)}
+4 -2
View File
@@ -63,8 +63,10 @@ export default function BenefitPage() {
return ( return (
<PageShell variant="tab" className="benefit-page"> <PageShell variant="tab" className="benefit-page">
<View className="benefit-header" style={navBarStyle(metrics)}> <View className="benefit-header" style={navBarStyle(metrics)} aria-label="好客权益">
<Text className="benefit-header-title"></Text> {process.env.TARO_ENV !== 'h5' ? (
<Text className="benefit-header-title"></Text>
) : null}
<View <View
className="benefit-header__content" className="benefit-header__content"
style={tabNavContentStyle(metrics)} style={tabNavContentStyle(metrics)}
+4 -1
View File
@@ -65,7 +65,10 @@ export default function HomePage() {
const onSale = tab === 'QINGXIANG'; const onSale = tab === 'QINGXIANG';
return ( return (
<PageShell variant="tab" className="home-page"> <PageShell
variant="tab"
className={`home-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="杜康好客" /> <TabMainHeader title="杜康好客" />
<View className="home-aroma-nav"> <View className="home-aroma-nav">
+55 -30
View File
@@ -329,6 +329,33 @@ export default function LoginPage() {
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}> <Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
使 使
</Text> </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 ? ( {showWechatLogin ? (
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} /> <WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
) : null} ) : null}
@@ -376,6 +403,34 @@ export default function LoginPage() {
</Text> </Text>
) : null} ) : null}
<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 <View
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`} className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
onClick={loading ? undefined : () => void login()} onClick={loading ? undefined : () => void login()}
@@ -415,36 +470,6 @@ export default function LoginPage() {
</> </>
) : null} ) : null}
</View> </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"
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>
</PageShell> </PageShell>
); );
} }
+8 -2
View File
@@ -210,7 +210,10 @@ export default function MinePage() {
if (!authed) { if (!authed) {
return ( return (
<PageShell variant="tab" className="mine-page"> <PageShell
variant="tab"
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="我的" /> <TabMainHeader title="我的" />
<View className="mine-header"> <View className="mine-header">
<View className="mine-header-texture" /> <View className="mine-header-texture" />
@@ -247,7 +250,10 @@ export default function MinePage() {
const memberLabel = hasWechat ? '好客会员' : canWxBind ? '微信未授权' : '未授权微信'; const memberLabel = hasWechat ? '好客会员' : canWxBind ? '微信未授权' : '未授权微信';
return ( return (
<PageShell variant="tab" className="mine-page"> <PageShell
variant="tab"
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="我的" /> <TabMainHeader title="我的" />
<View className="mine-header"> <View className="mine-header">
<View className="mine-header-texture" /> <View className="mine-header-texture" />
@@ -42,6 +42,9 @@ type OrderPreview = {
payAmount: number; payAmount: number;
benefitAmount: number; benefitAmount: number;
city?: { localMinQty: number; crossMinQty: number }; city?: { localMinQty: number; crossMinQty: number };
quantityOk?: boolean;
quantityMessage?: string | null;
minQty?: number;
}; };
function formatAddress(a: Address) { function formatAddress(a: Address) {
@@ -53,7 +56,7 @@ export default function OrderConfirmPage() {
const checkoutCtx = readCheckoutContext(router.params); const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? ''; const productId = checkoutCtx.productId ?? '';
const forceCross = checkoutCtx.cross === true; 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 [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(checkoutCtx.addressId || ''); const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
const [preview, setPreview] = useState<OrderPreview | null>(null); const [preview, setPreview] = useState<OrderPreview | null>(null);
@@ -91,7 +94,7 @@ export default function OrderConfirmPage() {
.then((data) => { .then((data) => {
if (!cancelled) { if (!cancelled) {
setPreview(data); setPreview(data);
setMsg(''); setMsg(data.quantityOk === false ? (data.quantityMessage || '') : '');
} }
}) })
.catch((e) => { .catch((e) => {
@@ -115,18 +118,14 @@ export default function OrderConfirmPage() {
); );
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY'; 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) { function updateQuantity(next: number) {
if (next < minQty) { if (next < 1) return;
setMsg(
!isCross
? `同城配送至少购买 ${minQty}`
: `跨城配送至少购买 ${minQty} 瓶(1箱)`,
);
return;
}
setMsg('');
setQuantity(next); setQuantity(next);
} }
@@ -158,8 +157,18 @@ export default function OrderConfirmPage() {
} }
async function submit() { async function submit() {
if (!addressId) { if (!canSubmit) {
setMsg('请选择收货地址'); if (!addressId) {
setMsg('请选择收货地址');
return;
}
if (!quantityOk) {
setMsg(
isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`,
);
}
return; return;
} }
@@ -206,6 +215,13 @@ export default function OrderConfirmPage() {
} }
const productImage = preview?.product ? getProductMainImage(preview.product) : ''; const productImage = preview?.product ? getProductMainImage(preview.product) : '';
const submitLabel = loading
? '提交中…'
: !addressId
? '请选择地址'
: !quantityOk
? `至少购买 ${minQty}`
: '提交订单';
return ( return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter> <PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
@@ -241,7 +257,8 @@ export default function OrderConfirmPage() {
{isCross ? ( {isCross ? (
<View className="order-card"> <View className="order-card">
<Text className="u-muted"> <Text className="u-muted">
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}
</Text> </Text>
</View> </View>
) : null} ) : null}
@@ -269,7 +286,7 @@ export default function OrderConfirmPage() {
<Text></Text> <Text></Text>
<View className="order-qty-controls"> <View className="order-qty-controls">
<View <View
className="order-qty-btn" className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(quantity - 1)} onClick={() => updateQuantity(quantity - 1)}
> >
<Text></Text> <Text></Text>
@@ -283,6 +300,13 @@ export default function OrderConfirmPage() {
</View> </View>
</View> </View>
</View> </View>
{!quantityOk ? (
<Text className="order-qty-hint">
{isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
</Text>
) : null}
</View> </View>
<View className="order-card"> <View className="order-card">
@@ -320,10 +344,13 @@ export default function OrderConfirmPage() {
</Text> </Text>
</View> </View>
<View <View
className="order-confirm-submit" className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
onClick={() => !loading && void submit()} onClick={() => {
if (!canSubmit) return;
void submit();
}}
> >
<Text>{loading ? '提交中…' : !addressId ? '请选择地址' : '提交订单'}</Text> <Text>{submitLabel}</Text>
</View> </View>
</View> </View>
</PageShell> </PageShell>
+4 -1
View File
@@ -73,7 +73,10 @@ export default function StoresPage() {
} }
return ( return (
<PageShell variant="tab" className="store-page"> <PageShell
variant="tab"
className={`store-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="门店" /> <TabMainHeader title="门店" />
<View className="store-toolbar"> <View className="store-toolbar">
<View className="store-location" onClick={() => setRegionOpen(true)}> <View className="store-location" onClick={() => setRegionOpen(true)}>
+2 -1
View File
@@ -306,8 +306,9 @@
.login-agreement { .login-agreement {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 8px; gap: 8px;
margin: 12px 0 16px;
} }
.login-agreement-check { .login-agreement-check {
+6 -7
View File
@@ -20,14 +20,13 @@
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
} }
/* ── Tab 主页面顶栏(实底 sticky ── */ /* H5 无 Tab 顶栏:贴顶,去掉标题栏留白 */
.tab-main-header { .page-shell.h5-no-tab-header {
position: sticky; --nav-bar-height: 0px;
}
.page-shell.h5-no-tab-header .home-aroma-nav {
top: 0; top: 0;
z-index: 50;
background: var(--color-background);
box-shadow: var(--shadow-card);
box-sizing: border-box;
} }
.tab-main-header__content { .tab-main-header__content {
+20
View File
@@ -93,6 +93,11 @@
color: var(--color-on-surface); color: var(--color-on-surface);
} }
.order-qty-btn--disabled {
opacity: 0.4;
pointer-events: none;
}
.order-qty-value { .order-qty-value {
margin: 0 14px; margin: 0 14px;
font-size: 16px; font-size: 16px;
@@ -101,6 +106,14 @@
text-align: center; text-align: center;
} }
.order-qty-hint {
display: block;
margin-top: 10px;
font-size: 13px;
color: var(--color-heritage-red);
line-height: 1.4;
}
.order-row { .order-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -180,6 +193,13 @@
justify-content: center; justify-content: center;
} }
.order-confirm-submit--disabled {
background: #c9c5c0;
color: #fff;
opacity: 0.85;
pointer-events: none;
}
.order-tabs { .order-tabs {
display: flex; display: flex;
padding: 0 var(--space-page); padding: 0 var(--space-page);
+2
View File
@@ -46,6 +46,8 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}"
# 生产部署 pathnginx 入口为 /user/ # 生产部署 pathnginx 入口为 /user/
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}" export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}" export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
# C 端 H5 编译期注入的 API origin(勿落到 localhost
export VITE_API_TARGET="${VITE_API_TARGET:-https://dkapi.runxian.top}"
pnpm approve-builds --all 2>/dev/null || true pnpm approve-builds --all 2>/dev/null || true
pnpm install --frozen-lockfile 2>/dev/null || pnpm install pnpm install --frozen-lockfile 2>/dev/null || pnpm install
@@ -89,3 +89,9 @@ export const PARTNER_PERMISSION_LABELS: Record<PartnerPermissionKey, string> = {
'store:create': '开店管理', 'store:create': '开店管理',
'order:view': '订单查看', 'order:view': '订单查看',
}; };
/** 门店类子账号默认权限:录入、开闭店、维护资料 */
export const DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS: PartnerPermissionKey[] = [
'store:create',
'store:manage',
];
+21 -1
View File
@@ -1,4 +1,4 @@
import type { WarehouseManagerType, WarehouseStatus } from './enums'; import type { WarehouseFulfillmentMode, WarehouseManagerType, WarehouseStatus } from './enums';
export interface CityWarehouseDto { export interface CityWarehouseDto {
id: string; id: string;
@@ -11,6 +11,14 @@ export interface CityWarehouseDto {
partnerAccountId: string | null; partnerAccountId: string | null;
partnerCompanyName?: string | null; partnerCompanyName?: string | null;
status: WarehouseStatus; status: WarehouseStatus;
fulfillmentMode: WarehouseFulfillmentMode;
fulfillmentProviderId: string | null;
fulfillmentProviderName?: string | null;
fulfillmentProviderCode?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -23,6 +31,12 @@ export interface CreateCityWarehouseInput {
managerType: WarehouseManagerType; managerType: WarehouseManagerType;
partnerAccountId?: string; partnerAccountId?: string;
status?: WarehouseStatus; status?: WarehouseStatus;
fulfillmentMode?: WarehouseFulfillmentMode;
fulfillmentProviderId?: string;
manualCarrierLabel?: string;
manualQueryUrlTemplate?: string;
lng?: number;
lat?: number;
} }
export interface UpdateCityWarehouseInput { export interface UpdateCityWarehouseInput {
@@ -33,4 +47,10 @@ export interface UpdateCityWarehouseInput {
managerType?: WarehouseManagerType; managerType?: WarehouseManagerType;
partnerAccountId?: string | null; partnerAccountId?: string | null;
status?: WarehouseStatus; status?: WarehouseStatus;
fulfillmentMode?: WarehouseFulfillmentMode;
fulfillmentProviderId?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
} }
+30
View File
@@ -168,6 +168,36 @@ export const WAREHOUSE_STATUS_LABELS: Record<WarehouseStatus, string> = {
[WarehouseStatus.PAUSED]: '暂停', [WarehouseStatus.PAUSED]: '暂停',
}; };
export enum FulfillmentProviderType {
API = 'API',
MANUAL = 'MANUAL',
}
export enum FulfillmentProviderStatus {
ACTIVE = 'ACTIVE',
DISABLED = 'DISABLED',
}
export enum WarehouseFulfillmentMode {
API_AUTO = 'API_AUTO',
MANUAL = 'MANUAL',
}
export const FULFILLMENT_PROVIDER_TYPE_LABELS: Record<FulfillmentProviderType, string> = {
[FulfillmentProviderType.API]: 'API 对接',
[FulfillmentProviderType.MANUAL]: '自管',
};
export const FULFILLMENT_PROVIDER_STATUS_LABELS: Record<FulfillmentProviderStatus, string> = {
[FulfillmentProviderStatus.ACTIVE]: '启用',
[FulfillmentProviderStatus.DISABLED]: '停用',
};
export const WAREHOUSE_FULFILLMENT_MODE_LABELS: Record<WarehouseFulfillmentMode, string> = {
[WarehouseFulfillmentMode.API_AUTO]: '自动发货',
[WarehouseFulfillmentMode.MANUAL]: '关闭(手工填单)',
};
export enum AccountStatus { export enum AccountStatus {
ACTIVE = 'ACTIVE', ACTIVE = 'ACTIVE',
DISABLED = 'DISABLED', DISABLED = 'DISABLED',
@@ -0,0 +1,80 @@
import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums';
import type { WarehouseFulfillmentMode } from './enums';
/** 小飞侠仓配凭证(存 FulfillmentProvider.configJson */
export interface XiaofeixiaProviderConfig {
apiUrl: string;
mchId: string;
apiKey: string;
signType?: 'MD5' | 'HMAC-SHA256';
appId?: string;
}
/** 回显用(不含明文 apiKey */
export interface XiaofeixiaProviderConfigPublic {
apiUrl: string;
mchId: string;
signType: 'MD5' | 'HMAC-SHA256';
appId?: string;
hasApiKey: boolean;
}
export interface FulfillmentProviderDto {
id: string;
code: string;
name: string;
type: FulfillmentProviderType;
status: FulfillmentProviderStatus;
capabilities?: {
createShipment?: boolean;
getTrack?: boolean;
callback?: boolean;
cancel?: boolean;
} | null;
hasConfig: boolean;
/** 小飞侠等承运商结构化配置(脱敏) */
xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null;
createdAt: string;
updatedAt: string;
}
export interface CreateFulfillmentProviderInput {
code: string;
name: string;
type: FulfillmentProviderType;
status?: FulfillmentProviderStatus;
configJson?: string;
capabilitiesJson?: string;
/** 结构化小飞侠配置;有则覆盖写入 configJson */
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
}
export interface UpdateFulfillmentProviderInput {
name?: string;
type?: FulfillmentProviderType;
status?: FulfillmentProviderStatus;
configJson?: string;
capabilitiesJson?: string;
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
}
export interface ManualShipOrderInput {
logisticsCompany: string;
trackingNo: string;
manualQueryUrl?: string;
}
export interface WarehouseFulfillmentConfig {
fulfillmentMode: WarehouseFulfillmentMode;
fulfillmentProviderId?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
}
export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
export function isXfxProviderCode(code: string): boolean {
return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
}
+1
View File
@@ -18,5 +18,6 @@ export * from './partner';
export * from './shop'; export * from './shop';
export * from './city-partner'; export * from './city-partner';
export * from './city-warehouse'; export * from './city-warehouse';
export * from './fulfillment-provider';
export * from './system-config'; export * from './system-config';
export * from './legal'; export * from './legal';
+1 -1
View File
@@ -25,7 +25,7 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly s
'partner_store_audit_rejected', 'partner_store_audit_rejected',
], ],
shipping: ['partner_order_ship', 'partner_delivery_advance'], shipping: ['partner_order_ship', 'partner_delivery_advance'],
settlement: ['partner_bill_view', 'partner_bill_detail_view'], settlement: ['partner_bill_view', 'partner_bill_detail_view', 'partner_bill_confirm'],
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'], warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
}; };
+11
View File
@@ -7,9 +7,20 @@ export interface PartnerMe {
isPrimary: boolean; isPrimary: boolean;
companyName?: string; companyName?: string;
hasWechat?: boolean; hasWechat?: boolean;
/** 微信授权后的昵称 */
wxNickname?: string | null;
/** 微信授权后的头像 URL */
wxAvatarUrl?: string | null;
staffRole?: PartnerStaffRole; staffRole?: PartnerStaffRole;
permissions?: string[]; permissions?: string[];
primaryAccountId?: string; primaryAccountId?: string;
/** 子账号联系主账号用 */
primaryPhone?: string;
primaryName?: string;
/** 主账号绑定的管仓 ID(有则本城酒单推送至该仓) */
managedWarehouseId?: string | null;
/** 是否已配置仓库管理(无则不展示/不推送酒订单) */
hasWarehouseAccess?: boolean;
} }
export interface UpdatePartnerMeRequest { export interface UpdatePartnerMeRequest {
+6 -3
View File
@@ -7,21 +7,24 @@ export interface StorePayoutDto {
paidAt?: string | null; paidAt?: string | null;
} }
export type PartnerBillStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'REJECTED';
export interface PartnerBillDto { export interface PartnerBillDto {
id: string; id: string;
billNo: string; billNo: string;
orderCommission: number; orderCommission: number;
redeemCommission: number; redeemCommission: number;
totalAmount: number; totalAmount: number;
status: 'DRAFT' | 'CONFIRMED' | 'PAID'; status: PartnerBillStatus;
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
confirmedAt?: string | null;
paidAt?: string | null;
rejectReason?: string | null;
} }
export interface PartnerBillDetailDto extends PartnerBillDto { export interface PartnerBillDetailDto extends PartnerBillDto {
partnerId: string; partnerId: string;
confirmedAt?: string | null;
paidAt?: string | null;
} }
/** 门店结算默认比例(核销金额 × 比例) */ /** 门店结算默认比例(核销金额 × 比例) */
+15 -3
View File
@@ -3,11 +3,19 @@ type PageHeaderProps = {
onBack?: () => void; onBack?: () => void;
backLabel?: string; backLabel?: string;
right?: React.ReactNode; right?: React.ReactNode;
/** 是否展示居中标题;H5 微信内默认隐藏,避免与系统标题栏重复 */
showTitle?: boolean;
}; };
export default function PageHeader({ title, onBack, backLabel = '←', right }: PageHeaderProps) { export default function PageHeader({
title,
onBack,
backLabel = '←',
right,
showTitle = false,
}: PageHeaderProps) {
return ( return (
<header className="page-header"> <header className="page-header" aria-label={title}>
<div className="page-header-side"> <div className="page-header-side">
{onBack ? ( {onBack ? (
<button type="button" className="back-btn" onClick={onBack} aria-label="返回"> <button type="button" className="back-btn" onClick={onBack} aria-label="返回">
@@ -17,7 +25,11 @@ export default function PageHeader({ title, onBack, backLabel = '←', right }:
<span className="page-header-spacer" /> <span className="page-header-spacer" />
)} )}
</div> </div>
<div className="app-page-title page-header-title">{title}</div> {showTitle ? (
<div className="app-page-title page-header-title">{title}</div>
) : (
<div className="page-header-title page-header-title--spacer" aria-hidden="true" />
)}
<div className="page-header-side page-header-side--right">{right ?? <span className="page-header-spacer" />}</div> <div className="page-header-side page-header-side--right">{right ?? <span className="page-header-spacer" />}</div>
</header> </header>
); );
+7
View File
@@ -531,6 +531,13 @@ button, input, select, textarea { font: inherit; }
flex: 1; flex: 1;
} }
.page-header-title--spacer {
font-size: 0;
line-height: 0;
min-height: 0;
flex: 1;
}
/* Coupon badge (ticket notch) */ /* Coupon badge (ticket notch) */
.coupon-badge { .coupon-badge {
position: relative; position: relative;
+1 -1
View File
@@ -16,7 +16,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.resolve(__dirname, '../apps/mini-user'); const APP_ROOT = path.resolve(__dirname, '../apps/mini-user');
const DIST = path.resolve(APP_ROOT, 'dist'); const DIST = path.resolve(APP_ROOT, 'dist');
const PORT = Number(process.env.PORT || 5177); const PORT = Number(process.env.PORT || 5177);
const API_ORIGIN = (process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top').replace(/\/$/, ''); const API_ORIGIN = (process.env.VITE_API_TARGET ?? 'http://localhost:3000').replace(/\/$/, '');
const TARO_BIN = path.resolve(APP_ROOT, 'node_modules/@tarojs/cli/bin/taro'); const TARO_BIN = path.resolve(APP_ROOT, 'node_modules/@tarojs/cli/bin/taro');
const MIME = { const MIME = {
+1
View File
@@ -13,6 +13,7 @@ async function main() {
await prisma.storeAccount.deleteMany(); await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany(); await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany(); await prisma.partnerBill.deleteMany();
await prisma.fulfillmentProvider.deleteMany();
await prisma.cityWarehouse.deleteMany(); await prisma.cityWarehouse.deleteMany();
await prisma.partnerAccount.deleteMany(); await prisma.partnerAccount.deleteMany();
const result = await prisma.commonCity.deleteMany(); const result = await prisma.commonCity.deleteMany();
+82 -29
View File
@@ -141,6 +141,21 @@ enum WarehouseStatus {
PAUSED PAUSED
} }
enum FulfillmentProviderType {
API
MANUAL
}
enum FulfillmentProviderStatus {
ACTIVE
DISABLED
}
enum WarehouseFulfillmentMode {
API_AUTO
MANUAL
}
enum PartnerStaffRole { enum PartnerStaffRole {
PARTNER PARTNER
INTERNAL INTERNAL
@@ -168,6 +183,7 @@ enum PartnerBillStatus {
DRAFT DRAFT
CONFIRMED CONFIRMED
PAID PAID
REJECTED
} }
enum StoreStatus { enum StoreStatus {
@@ -496,25 +512,51 @@ model CommonCity {
@@map("common_city") @@map("common_city")
} }
model CityWarehouse { model FulfillmentProvider {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt code String @unique @db.VarChar(32)
name String @db.VarChar(128) name String @db.VarChar(128)
address String @db.VarChar(256) type FulfillmentProviderType
contactName String @map("contact_name") @db.VarChar(64) status FulfillmentProviderStatus @default(ACTIVE)
contactPhone String @map("contact_phone") @db.VarChar(20) configJson String? @map("config_json") @db.Text
managerType WarehouseManagerType @map("manager_type") capabilitiesJson String? @map("capabilities_json") @db.Text
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
status WarehouseStatus @default(ACTIVE) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade) warehouses CityWarehouse[]
partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull) deliveries OrderDelivery[]
managedBy PartnerAccount? @relation("ManagedWarehouse")
@@map("common_fulfillment_provider")
}
model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
name String @db.VarChar(128)
address String @db.VarChar(256)
contactName String @map("contact_name") @db.VarChar(64)
contactPhone String @map("contact_phone") @db.VarChar(20)
managerType WarehouseManagerType @map("manager_type")
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
status WarehouseStatus @default(ACTIVE)
fulfillmentMode WarehouseFulfillmentMode @default(MANUAL) @map("fulfillment_mode")
fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt
manualCarrierLabel String? @map("manual_carrier_label") @db.VarChar(64)
manualQueryUrlTemplate String? @map("manual_query_url_template") @db.VarChar(512)
lng Decimal? @db.Decimal(10, 7)
lat Decimal? @db.Decimal(10, 7)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull)
managedBy PartnerAccount? @relation("ManagedWarehouse")
fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull)
fulfilledOrders Order[] @relation("OrderFulfillmentWarehouse")
@@index([cityId]) @@index([cityId])
@@index([partnerAccountId]) @@index([partnerAccountId])
@@index([fulfillmentProviderId])
@@map("common_city_warehouse") @@map("common_city_warehouse")
} }
@@ -526,6 +568,8 @@ model PartnerAccount {
name String @db.VarChar(64) name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64) wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64) wxUnionId String? @map("wx_union_id") @db.VarChar(64)
wxNickname String? @map("wx_nickname") @db.VarChar(64)
wxAvatarUrl String? @map("wx_avatar_url") @db.VarChar(512)
isPrimary Int @default(0) @map("is_primary") @db.TinyInt isPrimary Int @default(0) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole PartnerStaffRole? @map("staff_role") staffRole PartnerStaffRole? @map("staff_role")
@@ -580,6 +624,7 @@ model PartnerBill {
status PartnerBillStatus @default(DRAFT) status PartnerBillStatus @default(DRAFT)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3) confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3) paidAt DateTime? @map("paid_at") @db.DateTime(3)
rejectReason String? @map("reject_reason") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict) partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
@@ -859,12 +904,14 @@ model Order {
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3) payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4) orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt
remark String? @db.VarChar(512) remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict) user User @relation(fields: [userId], references: [id], onDelete: Restrict)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict) city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
fulfillmentWarehouse CityWarehouse? @relation("OrderFulfillmentWarehouse", fields: [fulfillmentWarehouseId], references: [id], onDelete: SetNull)
originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull) originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull)
reshipments Order[] @relation("OrderReshipment") reshipments Order[] @relation("OrderReshipment")
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull) promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
@@ -880,24 +927,30 @@ model Order {
@@index([payExternalNo]) @@index([payExternalNo])
@@index([ipCity]) @@index([ipCity])
@@index([gpsCity]) @@index([gpsCity])
@@index([fulfillmentWarehouseId])
@@map("user_order") @@map("user_order")
} }
model OrderDelivery { model OrderDelivery {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
provider DeliveryProvider provider DeliveryProvider
providerOrderNo String? @map("provider_order_no") @db.VarChar(64) fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt
trackingNo String? @map("tracking_no") @db.VarChar(64) providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3) trackingNo String? @map("tracking_no") @db.VarChar(64)
shippingAt DateTime? @map("shipping_at") @db.DateTime(3) logisticsCompany String? @map("logistics_company") @db.VarChar(64)
deliveredAt DateTime? @map("delivered_at") @db.DateTime(3) manualQueryUrl String? @map("manual_query_url") @db.VarChar(512)
signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull) fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull)
signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
@@index([fulfillmentProviderId])
@@map("user_order_delivery") @@map("user_order_delivery")
} }
+31
View File
@@ -91,6 +91,8 @@ async function main() {
await prisma.cityWarehouse.deleteMany(); await prisma.cityWarehouse.deleteMany();
await prisma.fulfillmentProvider.deleteMany();
await prisma.partnerAccount.deleteMany(); await prisma.partnerAccount.deleteMany();
await prisma.commonCity.deleteMany(); await prisma.commonCity.deleteMany();
@@ -175,6 +177,27 @@ async function main() {
await prisma.fulfillmentProvider.create({
data: {
code: 'XFX',
name: '小飞侠',
type: 'API',
status: 'ACTIVE',
capabilitiesJson: JSON.stringify({
createShipment: true,
getTrack: true,
callback: true,
cancel: true,
}),
configJson: JSON.stringify({
apiUrl: 'https://beta.51xiaoju.cn/app/api/interface.do',
mchId: '',
apiKey: '',
signType: 'MD5',
}),
},
});
const warehouse = await prisma.cityWarehouse.create({ const warehouse = await prisma.cityWarehouse.create({
data: { data: {
@@ -195,6 +218,14 @@ async function main() {
status: 'ACTIVE', status: 'ACTIVE',
fulfillmentMode: 'MANUAL',
fulfillmentProviderId: null,
lng: 113.665,
lat: 34.757,
}, },
}); });
@@ -0,0 +1,7 @@
import { SetMetadata } from '@nestjs/common';
import type { PartnerPermissionKey } from '@dukang/shared-types';
export const PARTNER_PERMISSIONS_KEY = 'partner_permissions';
export const RequirePartnerPermissions = (...permissions: PartnerPermissionKey[]) =>
SetMetadata(PARTNER_PERMISSIONS_KEY, permissions);
@@ -0,0 +1,45 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { PartnerPermissionKey } from '@dukang/shared-types';
import { PARTNER_PERMISSIONS_KEY } from '../decorators/partner-permission.decorator';
import { PrismaService } from '../prisma/prisma.module';
import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class PartnerPermissionGuard implements CanActivate {
constructor(
private readonly jwtAuthGuard: JwtAuthGuard,
private readonly prisma: PrismaService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
await this.jwtAuthGuard.canActivate(context);
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser;
if (user.actorType !== 'PARTNER') {
throw new ForbiddenException('仅合伙人可操作');
}
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId },
});
if (account.isPrimary === 1) return true;
const required = this.reflector.getAllAndOverride<PartnerPermissionKey[]>(
PARTNER_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!required?.length) return true;
const perms = Array.isArray(account.permissions)
? (account.permissions as string[])
: [];
if (required.some((p) => perms.includes(p))) return true;
throw new ForbiddenException('当前子账号无此操作权限');
}
}
@@ -46,6 +46,7 @@ export const HqOperationAction = {
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE', PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM', PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM',
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID', PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN', REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM', REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE', PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
@@ -108,6 +109,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单', [HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
[HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单', [HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单',
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算', [HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码', [HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销', [HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
@@ -6,7 +6,6 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
{ key: 'sms', label: '短信' }, { key: 'sms', label: '短信' },
{ key: 'wechat', label: '微信' }, { key: 'wechat', label: '微信' },
{ key: 'oss', label: '对象存储 OSS' }, { key: 'oss', label: '对象存储 OSS' },
{ key: 'courier', label: '同城配送' },
{ key: 'app', label: '应用链接' }, { key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' }, { key: 'deploy', label: '发布部署' },
]; ];
@@ -16,7 +15,6 @@ const G = {
sms: 'sms', sms: 'sms',
wechat: 'wechat', wechat: 'wechat',
oss: 'oss', oss: 'oss',
courier: 'courier',
app: 'app', app: 'app',
deploy: 'deploy', deploy: 'deploy',
} as const; } as const;
@@ -79,19 +77,6 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{ key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'COURIER_PROVIDER', label: '配送服务商', group: G.courier, type: 'string', requiresRestart: true, placeholder: 'xiaofeixia' },
{ key: 'XIAOFEIXIA_API_URL', label: '小飞侠 API 地址', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'XIAOFEIXIA_MCH_ID', label: '小飞侠商户号', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'XIAOFEIXIA_API_KEY', label: '小飞侠 API Key', group: G.courier, type: 'password', secret: true, requiresRestart: false },
{ key: 'XIAOFEIXIA_SIGN_TYPE', label: '小飞侠签名类型', group: G.courier, type: 'string', requiresRestart: false, placeholder: 'MD5' },
{ key: 'XIAOFEIXIA_APP_ID', label: '小飞侠 AppID', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_NAME', label: '默认寄件人', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_MOBILE', label: '默认寄件手机', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_ADDRESS', label: '默认寄件地址', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_ADDRESS_DETAIL', label: '默认寄件门牌', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_LNG', label: '默认寄件经度', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_LAT', label: '默认寄件纬度', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false }, { key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
{ key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key', group: G.app, type: 'password', secret: true, requiresRestart: false }, { key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key', group: G.app, type: 'password', secret: true, requiresRestart: false },
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { COURIER_PROVIDER } from './courier.constants'; import { COURIER_PROVIDER } from './courier.constants';
import type { import type {
BatchShipmentQuery, BatchShipmentQuery,
CourierCallOptions,
CreateShipmentInput, CreateShipmentInput,
CreateShipmentResult, CreateShipmentResult,
DeliveryCoverageResult, DeliveryCoverageResult,
@@ -24,32 +25,32 @@ export class CourierService {
return this.provider.code; return this.provider.code;
} }
createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult> { createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
return this.provider.createShipment(input); return this.provider.createShipment(input, options);
} }
cancelShipment(query: ShipmentQuery): Promise<void> { cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
return this.provider.cancelShipment(query); return this.provider.cancelShipment(query, options);
} }
getShipment(query: ShipmentQuery): Promise<ShipmentDetail> { getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
return this.provider.getShipment(query); return this.provider.getShipment(query, options);
} }
batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]> { batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
return this.provider.batchGetShipments(query); return this.provider.batchGetShipments(query, options);
} }
getTrack(query: ShipmentQuery): Promise<TrackNode[]> { getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
return this.provider.getTrack(query); return this.provider.getTrack(query, options);
} }
checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult> { checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
return this.provider.checkDeliveryCoverage(toAddress); return this.provider.checkDeliveryCoverage(toAddress, options);
} }
estimateFreight(weight: number): Promise<FreightEstimateResult> { estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
return this.provider.estimateFreight(weight); return this.provider.estimateFreight(weight, options);
} }
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse { buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
@@ -1,4 +1,10 @@
import { CourierProviderCode } from './courier.constants'; import { CourierProviderCode } from './courier.constants';
import type { XiaofeixiaConfig } from './courier.config';
/** 调用时覆盖默认 env 配置(仓配管理里维护的凭证) */
export interface CourierCallOptions {
xiaofeixia?: XiaofeixiaConfig;
}
export interface CourierCoordinate { export interface CourierCoordinate {
lng: number; lng: number;
@@ -106,12 +112,12 @@ export interface TrackCallbackResponse {
export interface ICourierProvider { export interface ICourierProvider {
readonly code: CourierProviderCode; readonly code: CourierProviderCode;
createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult>; createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult>;
cancelShipment(query: ShipmentQuery): Promise<void>; cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void>;
getShipment(query: ShipmentQuery): Promise<ShipmentDetail>; getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail>;
batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]>; batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]>;
getTrack(query: ShipmentQuery): Promise<TrackNode[]>; getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]>;
checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult>; checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>;
estimateFreight(weight: number): Promise<FreightEstimateResult>; estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>;
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse; buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
} }
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CourierApiError } from '../courier.error'; import { CourierApiError } from '../courier.error';
import { CourierConfigService } from '../courier.config'; import { CourierConfigService, type XiaofeixiaConfig } from '../courier.config';
import { PrismaService } from '../../../common/prisma/prisma.module'; import { PrismaService } from '../../../common/prisma/prisma.module';
import { import {
logCourierCall, logCourierCall,
@@ -21,12 +21,12 @@ export class XiaofeixiaClient {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
) {} ) {}
async request<T>(cmd: string, bizParams: RequestParams): Promise<T> { async request<T>(cmd: string, bizParams: RequestParams, override?: XiaofeixiaConfig): Promise<T> {
const cfg = this.courierConfig.load().xiaofeixia; const cfg = override ?? this.courierConfig.load().xiaofeixia;
const scene = sceneForXfxCmd(cmd); const scene = sceneForXfxCmd(cmd);
const externalNo = this.pickExternalNo(bizParams); const externalNo = this.pickExternalNo(bizParams);
if (!cfg.mchId || !cfg.apiKey) { if (!cfg.mchId || !cfg.apiKey || !cfg.apiUrl) {
await logCourierCall(this.prisma, { await logCourierCall(this.prisma, {
scene, scene,
requestUrl: cfg.apiUrl || '(未配置)', requestUrl: cfg.apiUrl || '(未配置)',
@@ -36,7 +36,7 @@ export class XiaofeixiaClient {
externalNo, externalNo,
}); });
throw new CourierApiError( throw new CourierApiError(
'小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY', '小飞侠商户配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key',
'CONFIG_ERROR', 'CONFIG_ERROR',
'XIAOFEIXIA', 'XIAOFEIXIA',
); );
@@ -3,6 +3,7 @@ import { CourierProviderCode } from '../courier.constants';
import { CourierApiError } from '../courier.error'; import { CourierApiError } from '../courier.error';
import type { import type {
BatchShipmentQuery, BatchShipmentQuery,
CourierCallOptions,
CreateShipmentInput, CreateShipmentInput,
CreateShipmentResult, CreateShipmentResult,
DeliveryCoverageResult, DeliveryCoverageResult,
@@ -29,28 +30,32 @@ export class XiaofeixiaProvider implements ICourierProvider {
constructor(private readonly client: XiaofeixiaClient) {} constructor(private readonly client: XiaofeixiaClient) {}
async createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult> { async createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
const data = await this.client.request<XiaofeixiaCreateOrderData>(XIAOFEIXIA_CMD.CREATE_ORDER, { const data = await this.client.request<XiaofeixiaCreateOrderData>(
customerId: input.customerId, XIAOFEIXIA_CMD.CREATE_ORDER,
outNumber: input.outNumber, {
fromAddress: input.from.address, customerId: input.customerId,
fromAddressDetail: input.from.addressDetail, outNumber: input.outNumber,
fromCoordinate: this.formatCoordinate(input.from.coordinate), fromAddress: input.from.address,
fromMobile: input.from.mobile, fromAddressDetail: input.from.addressDetail,
fromName: input.from.name, fromCoordinate: this.formatCoordinate(input.from.coordinate),
toAddress: input.to.address, fromMobile: input.from.mobile,
toAddressDetail: input.to.addressDetail, fromName: input.from.name,
toCoordinate: this.formatCoordinate(input.to.coordinate), toAddress: input.to.address,
toMobile: input.to.mobile, toAddressDetail: input.to.addressDetail,
toName: input.to.name, toCoordinate: this.formatCoordinate(input.to.coordinate),
goodsName: input.goodsName, toMobile: input.to.mobile,
goodsNum: input.goodsNum, toName: input.to.name,
weight: input.weight, goodsName: input.goodsName,
insuredSumPrice: input.insuredSumPrice, goodsNum: input.goodsNum,
collectionPrice: input.collectionPrice, weight: input.weight,
payMode: input.payMode, insuredSumPrice: input.insuredSumPrice,
remark: input.remark, collectionPrice: input.collectionPrice,
}); payMode: input.payMode,
remark: input.remark,
},
options?.xiaofeixia,
);
return { return {
providerShipmentId: data.id, providerShipmentId: data.id,
@@ -58,24 +63,32 @@ export class XiaofeixiaProvider implements ICourierProvider {
}; };
} }
async cancelShipment(query: ShipmentQuery): Promise<void> { async cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
this.assertShipmentQuery(query); this.assertShipmentQuery(query);
await this.client.request(XIAOFEIXIA_CMD.CANCEL_ORDER, { await this.client.request(
number: query.trackingNumber, XIAOFEIXIA_CMD.CANCEL_ORDER,
outNumber: query.outNumber, {
}); number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
} }
async getShipment(query: ShipmentQuery): Promise<ShipmentDetail> { async getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
this.assertShipmentQuery(query); this.assertShipmentQuery(query);
const data = await this.client.request<XiaofeixiaOrderDetail>(XIAOFEIXIA_CMD.GET_ORDER, { const data = await this.client.request<XiaofeixiaOrderDetail>(
number: query.trackingNumber, XIAOFEIXIA_CMD.GET_ORDER,
outNumber: query.outNumber, {
}); number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
return this.mapOrderDetail(data); return this.mapOrderDetail(data);
} }
async batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]> { async batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
const number = query.trackingNumbers?.join(','); const number = query.trackingNumbers?.join(',');
const outNumber = query.outNumbers?.join(','); const outNumber = query.outNumbers?.join(',');
@@ -83,27 +96,36 @@ export class XiaofeixiaProvider implements ICourierProvider {
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code); throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
} }
const data = await this.client.request<XiaofeixiaOrderDetail[]>(XIAOFEIXIA_CMD.BATCH_GET_ORDER, { const data = await this.client.request<XiaofeixiaOrderDetail[]>(
number, XIAOFEIXIA_CMD.BATCH_GET_ORDER,
outNumber, {
}); number,
outNumber,
},
options?.xiaofeixia,
);
return (data ?? []).map((item) => this.mapOrderDetail(item)); return (data ?? []).map((item) => this.mapOrderDetail(item));
} }
async getTrack(query: ShipmentQuery): Promise<TrackNode[]> { async getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
this.assertShipmentQuery(query); this.assertShipmentQuery(query);
const data = await this.client.request<XiaofeixiaTrackNode[]>(XIAOFEIXIA_CMD.TRACK_ROUTE, { const data = await this.client.request<XiaofeixiaTrackNode[]>(
number: query.trackingNumber, XIAOFEIXIA_CMD.TRACK_ROUTE,
outNumber: query.outNumber, {
}); number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
return data ?? []; return data ?? [];
} }
async checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult> { async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>( const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
XIAOFEIXIA_CMD.DELIVERY_COVERAGE, XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
{ toAddress }, { toAddress },
options?.xiaofeixia,
); );
return { return {
@@ -113,10 +135,11 @@ export class XiaofeixiaProvider implements ICourierProvider {
}; };
} }
async estimateFreight(weight: number): Promise<FreightEstimateResult> { async estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
const data = await this.client.request<XiaofeixiaFreightEstimateData>( const data = await this.client.request<XiaofeixiaFreightEstimateData>(
XIAOFEIXIA_CMD.ESTIMATE_FREIGHT, XIAOFEIXIA_CMD.ESTIMATE_FREIGHT,
{ weight }, { weight },
options?.xiaofeixia,
); );
return { freightPrice: data.freightPrice }; return { freightPrice: data.freightPrice };
@@ -48,7 +48,7 @@ export class WechatMockProvider implements IWechatProvider {
return { return {
openId, openId,
nickname: 'Mock微信用户', nickname: 'Mock微信用户',
headImgUrl: undefined, headImgUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(openId)}`,
}; };
} }
@@ -1,5 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, WarehouseManagerType, WarehouseStatus } from '@prisma/client'; import {
Prisma,
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
} from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from './partner-city.service'; import { PartnerCityService } from './partner-city.service';
@@ -13,9 +18,27 @@ export type CreateCityWarehouseInput = {
managerType: WarehouseManagerType; managerType: WarehouseManagerType;
partnerAccountId?: bigint; partnerAccountId?: bigint;
status?: WarehouseStatus; status?: WarehouseStatus;
fulfillmentMode?: WarehouseFulfillmentMode;
fulfillmentProviderId?: bigint;
manualCarrierLabel?: string;
manualQueryUrlTemplate?: string;
lng?: number;
lat?: number;
}; };
export type UpdateCityWarehouseInput = Partial<CreateCityWarehouseInput>; export type UpdateCityWarehouseInput = Partial<
Omit<
CreateCityWarehouseInput,
'partnerAccountId' | 'fulfillmentProviderId' | 'manualCarrierLabel' | 'manualQueryUrlTemplate' | 'lng' | 'lat'
>
> & {
partnerAccountId?: bigint | null;
fulfillmentProviderId?: bigint | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
};
@Injectable() @Injectable()
export class CityWarehouseService { export class CityWarehouseService {
@@ -29,6 +52,7 @@ export class CityWarehouseService {
where: { cityId }, where: { cityId },
include: { include: {
partnerAccount: { select: { id: true, companyName: true } }, partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
@@ -51,6 +75,7 @@ export class CityWarehouseService {
take: pageSize, take: pageSize,
include: { include: {
partnerAccount: { select: { id: true, companyName: true } }, partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
city: { select: { id: true, name: true, code: true } }, city: { select: { id: true, name: true, code: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@@ -73,6 +98,7 @@ export class CityWarehouseService {
async create(cityId: bigint, input: CreateCityWarehouseInput) { async create(cityId: bigint, input: CreateCityWarehouseInput) {
await this.assertCityExists(cityId); await this.assertCityExists(cityId);
await this.validateManager(input.managerType, input.partnerAccountId, cityId); await this.validateManager(input.managerType, input.partnerAccountId, cityId);
await this.validateFulfillment(input.fulfillmentMode, input.fulfillmentProviderId);
const row = await this.prisma.cityWarehouse.create({ const row = await this.prisma.cityWarehouse.create({
data: { data: {
@@ -84,9 +110,17 @@ export class CityWarehouseService {
managerType: input.managerType, managerType: input.managerType,
partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null, partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null,
status: input.status ?? 'ACTIVE', status: input.status ?? 'ACTIVE',
fulfillmentMode: input.fulfillmentMode ?? 'MANUAL',
fulfillmentProviderId:
input.fulfillmentMode === 'API_AUTO' ? input.fulfillmentProviderId : null,
manualCarrierLabel: input.manualCarrierLabel?.trim() || null,
manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null,
lng: input.lng != null ? input.lng : null,
lat: input.lat != null ? input.lat : null,
}, },
include: { include: {
partnerAccount: { select: { id: true, companyName: true } }, partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
}, },
}); });
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId); await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
@@ -102,8 +136,16 @@ export class CityWarehouseService {
managerType === 'PARTNER' managerType === 'PARTNER'
? input.partnerAccountId ?? current.partnerAccountId ?? undefined ? input.partnerAccountId ?? current.partnerAccountId ?? undefined
: null; : null;
const fulfillmentMode = input.fulfillmentMode ?? current.fulfillmentMode;
const fulfillmentProviderId =
fulfillmentMode === 'API_AUTO'
? input.fulfillmentProviderId !== undefined
? input.fulfillmentProviderId
: current.fulfillmentProviderId
: null;
await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId); await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId);
await this.validateFulfillment(fulfillmentMode, fulfillmentProviderId ?? undefined);
const row = await this.prisma.cityWarehouse.update({ const row = await this.prisma.cityWarehouse.update({
where: { id }, where: { id },
@@ -117,9 +159,22 @@ export class CityWarehouseService {
? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null } ? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null }
: {}), : {}),
...(input.status !== undefined ? { status: input.status } : {}), ...(input.status !== undefined ? { status: input.status } : {}),
...(input.fulfillmentMode !== undefined ? { fulfillmentMode } : {}),
...(input.fulfillmentMode !== undefined || input.fulfillmentProviderId !== undefined
? { fulfillmentProviderId }
: {}),
...(input.manualCarrierLabel !== undefined
? { manualCarrierLabel: input.manualCarrierLabel?.trim() || null }
: {}),
...(input.manualQueryUrlTemplate !== undefined
? { manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null }
: {}),
...(input.lng !== undefined ? { lng: input.lng } : {}),
...(input.lat !== undefined ? { lat: input.lat } : {}),
}, },
include: { include: {
partnerAccount: { select: { id: true, companyName: true } }, partnerAccount: { select: { id: true, companyName: true } },
fulfillmentProvider: { select: { id: true, code: true, name: true } },
}, },
}); });
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId); await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
@@ -170,6 +225,19 @@ export class CityWarehouseService {
} }
} }
private async validateFulfillment(
mode?: WarehouseFulfillmentMode,
providerId?: bigint,
) {
if (mode === 'API_AUTO') {
if (!providerId) throw new BadRequestException('API 自动推单须选择仓配承运商');
const provider = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
throw new BadRequestException('所选仓配承运商不可用');
}
}
}
private async assertCityExists(cityId: bigint) { private async assertCityExists(cityId: bigint) {
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } }); const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
if (!city) throw new NotFoundException('开城城市不存在'); if (!city) throw new NotFoundException('开城城市不存在');
@@ -185,9 +253,16 @@ export class CityWarehouseService {
managerType: string; managerType: string;
partnerAccountId: bigint | null; partnerAccountId: bigint | null;
status: string; status: string;
fulfillmentMode: string;
fulfillmentProviderId: bigint | null;
manualCarrierLabel: string | null;
manualQueryUrlTemplate: string | null;
lng: Prisma.Decimal | null;
lat: Prisma.Decimal | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
partnerAccount?: { id: bigint; companyName: string | null } | null; partnerAccount?: { id: bigint; companyName: string | null } | null;
fulfillmentProvider?: { id: bigint; code: string; name: string } | null;
}) { }) {
return serializeBigInt({ return serializeBigInt({
id: row.id.toString(), id: row.id.toString(),
@@ -200,6 +275,14 @@ export class CityWarehouseService {
partnerAccountId: row.partnerAccountId?.toString() ?? null, partnerAccountId: row.partnerAccountId?.toString() ?? null,
partnerCompanyName: row.partnerAccount?.companyName ?? null, partnerCompanyName: row.partnerAccount?.companyName ?? null,
status: row.status, status: row.status,
fulfillmentMode: row.fulfillmentMode,
fulfillmentProviderId: row.fulfillmentProviderId?.toString() ?? null,
fulfillmentProviderName: row.fulfillmentProvider?.name ?? null,
fulfillmentProviderCode: row.fulfillmentProvider?.code ?? null,
manualCarrierLabel: row.manualCarrierLabel,
manualQueryUrlTemplate: row.manualQueryUrlTemplate,
lng: row.lng != null ? Number(row.lng) : null,
lat: row.lat != null ? Number(row.lat) : null,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}); });
@@ -101,7 +101,42 @@ export class PartnerCityService {
async buildPartnerOrderWhere(partnerAccountId: bigint): Promise<Prisma.OrderWhereInput> { async buildPartnerOrderWhere(partnerAccountId: bigint): Promise<Prisma.OrderWhereInput> {
const primary = await this.resolvePrimaryAccount(partnerAccountId); const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n }; if (!primary.cityId) return { id: -1n };
return { cityId: primary.cityId };
const warehouseIds = await this.resolveManagedWarehouseIds(primary.id);
// 未配置管仓:不推送/不展示酒订单(同城无仓由总部履约)
if (warehouseIds.length === 0) {
return { id: -1n };
}
return { fulfillmentWarehouseId: { in: warehouseIds } };
}
async hasManagedWarehouse(partnerAccountId: bigint): Promise<boolean> {
const ids = await this.resolveManagedWarehouseIds(partnerAccountId);
return ids.length > 0;
}
/** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */
async resolveManagedWarehouseIds(partnerAccountId: bigint): Promise<bigint[]> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
const ids = new Set<bigint>();
if (primary.managedWarehouseId) {
ids.add(primary.managedWarehouseId);
}
const managed = await this.prisma.cityWarehouse.findMany({
where: {
status: 'ACTIVE',
OR: [
{ partnerAccountId: primary.id },
...(primary.managedWarehouseId ? [{ id: primary.managedWarehouseId }] : []),
],
},
select: { id: true },
});
for (const row of managed) ids.add(row.id);
return [...ids];
} }
async buildPartnerCityWhere(partnerAccountId: bigint): Promise<Prisma.CommonCityWhereInput> { async buildPartnerCityWhere(partnerAccountId: bigint): Promise<Prisma.CommonCityWhereInput> {
@@ -0,0 +1,263 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
import {
isXfxProviderCode,
type XiaofeixiaProviderConfig,
type XiaofeixiaProviderConfigPublic,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { XiaofeixiaConfig, XiaofeixiaSignType } from '../../integrations/courier/courier.config';
export type CreateFulfillmentProviderInput = {
code: string;
name: string;
type: FulfillmentProviderType;
status?: FulfillmentProviderStatus;
configJson?: string;
capabilitiesJson?: string;
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
};
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
type Capabilities = {
createShipment?: boolean;
getTrack?: boolean;
callback?: boolean;
cancel?: boolean;
};
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
@Injectable()
export class FulfillmentProviderService {
constructor(private readonly prisma: PrismaService) {}
async listActiveApiProviders() {
const rows = await this.prisma.fulfillmentProvider.findMany({
where: { status: 'ACTIVE', type: 'API' },
orderBy: { name: 'asc' },
});
return rows.map((row) => this.toDto(row));
}
async listAll() {
const rows = await this.prisma.fulfillmentProvider.findMany({
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
}
async getById(id: bigint) {
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
if (!row) throw new NotFoundException('仓配承运商不存在');
return this.toDto(row);
}
/** 供推单使用:解析完整小飞侠凭证(含 apiKey) */
async resolveXiaofeixiaConfig(providerId: bigint): Promise<XiaofeixiaConfig> {
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
if (!row) throw new NotFoundException('仓配承运商不存在');
if (!isXfxProviderCode(row.code)) {
throw new BadRequestException('该承运商不是小飞侠');
}
const cfg = this.parseXiaofeixiaConfig(row.configJson);
if (!cfg?.mchId || !cfg?.apiKey || !cfg?.apiUrl) {
throw new BadRequestException('小飞侠仓配配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key');
}
return {
apiUrl: cfg.apiUrl,
mchId: cfg.mchId,
apiKey: cfg.apiKey,
signType: this.resolveSignType(cfg.signType),
appId: cfg.appId || undefined,
};
}
/** 取第一个启用的小飞侠承运商配置(联调/兼容) */
async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> {
const row = await this.prisma.fulfillmentProvider.findFirst({
where: {
status: 'ACTIVE',
type: 'API',
code: { in: ['XFX', 'XIAOFEIXIA'] },
},
orderBy: { updatedAt: 'desc' },
});
if (!row?.configJson) return null;
try {
return await this.resolveXiaofeixiaConfig(row.id);
} catch {
return null;
}
}
async create(input: CreateFulfillmentProviderInput) {
const code = input.code.trim().toUpperCase();
if (!/^[A-Z0-9_]+$/.test(code)) {
throw new BadRequestException('承运商编码仅支持大写字母、数字和下划线');
}
const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } });
if (existing) throw new BadRequestException('承运商编码已存在');
const configJson = this.resolveConfigJsonForWrite(code, null, input);
if (isXfxProviderCode(code) && input.type === 'API') {
this.assertXiaofeixiaConfigComplete(configJson, true);
}
const row = await this.prisma.fulfillmentProvider.create({
data: {
code,
name: input.name.trim(),
type: input.type,
status: input.status ?? 'ACTIVE',
configJson,
capabilitiesJson:
input.capabilitiesJson?.trim() ||
(isXfxProviderCode(code)
? JSON.stringify({
createShipment: true,
getTrack: true,
callback: true,
cancel: true,
})
: null),
},
});
return this.toDto(row);
}
async update(id: bigint, input: UpdateFulfillmentProviderInput) {
const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓配承运商不存在');
const code = current.code;
const configJson =
input.xiaofeixiaConfig !== undefined || input.configJson !== undefined
? this.resolveConfigJsonForWrite(code, current.configJson, input)
: undefined;
if (configJson !== undefined && isXfxProviderCode(code) && (input.type ?? current.type) === 'API') {
this.assertXiaofeixiaConfigComplete(configJson, false);
}
const row = await this.prisma.fulfillmentProvider.update({
where: { id },
data: {
...(input.name !== undefined ? { name: input.name.trim() } : {}),
...(input.type !== undefined ? { type: input.type } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(configJson !== undefined ? { configJson } : {}),
...(input.capabilitiesJson !== undefined
? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
: {}),
},
});
return this.toDto(row);
}
parseCapabilities(raw: string | null): Capabilities | null {
if (!raw) return null;
try {
return JSON.parse(raw) as Capabilities;
} catch {
return null;
}
}
parseXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfig | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<XiaofeixiaProviderConfig>;
if (!parsed || typeof parsed !== 'object') return null;
return {
apiUrl: String(parsed.apiUrl ?? '').trim(),
mchId: String(parsed.mchId ?? '').trim(),
apiKey: String(parsed.apiKey ?? '').trim(),
signType: parsed.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
appId: parsed.appId ? String(parsed.appId).trim() : undefined,
};
} catch {
return null;
}
}
private resolveConfigJsonForWrite(
code: string,
existingRaw: string | null,
input: CreateFulfillmentProviderInput | UpdateFulfillmentProviderInput,
): string | null {
if (isXfxProviderCode(code) && input.xiaofeixiaConfig) {
const existing = this.parseXiaofeixiaConfig(existingRaw);
const next: XiaofeixiaProviderConfig = {
apiUrl: (input.xiaofeixiaConfig.apiUrl ?? existing?.apiUrl ?? DEFAULT_XFX_API_URL).trim(),
mchId: (input.xiaofeixiaConfig.mchId ?? existing?.mchId ?? '').trim(),
apiKey: (input.xiaofeixiaConfig.apiKey || existing?.apiKey || '').trim(),
signType:
input.xiaofeixiaConfig.signType === 'HMAC-SHA256'
? 'HMAC-SHA256'
: input.xiaofeixiaConfig.signType === 'MD5'
? 'MD5'
: existing?.signType ?? 'MD5',
appId: (input.xiaofeixiaConfig.appId ?? existing?.appId)?.trim() || undefined,
};
return JSON.stringify(next);
}
if (input.configJson !== undefined) {
return input.configJson?.trim() || null;
}
return existingRaw;
}
private assertXiaofeixiaConfigComplete(configJson: string | null, requireApiKey: boolean) {
const cfg = this.parseXiaofeixiaConfig(configJson);
if (!cfg?.apiUrl) throw new BadRequestException('请填写小飞侠 API 地址');
if (!cfg.mchId) throw new BadRequestException('请填写小飞侠商户号');
if (requireApiKey && !cfg.apiKey) throw new BadRequestException('请填写小飞侠 API Key');
if (!requireApiKey && !cfg.apiKey) throw new BadRequestException('小飞侠 API Key 缺失,请重新填写');
}
private toPublicXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfigPublic | null {
const cfg = this.parseXiaofeixiaConfig(raw);
if (!cfg) return null;
return {
apiUrl: cfg.apiUrl || DEFAULT_XFX_API_URL,
mchId: cfg.mchId,
signType: cfg.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
appId: cfg.appId,
hasApiKey: Boolean(cfg.apiKey),
};
}
private resolveSignType(raw?: string): XiaofeixiaSignType {
return raw?.toUpperCase() === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5';
}
private toDto(row: {
id: bigint;
code: string;
name: string;
type: string;
status: string;
configJson: string | null;
capabilitiesJson: string | null;
createdAt: Date;
updatedAt: Date;
}) {
return serializeBigInt({
id: row.id.toString(),
code: row.code,
name: row.name,
type: row.type,
status: row.status,
capabilities: this.parseCapabilities(row.capabilitiesJson),
hasConfig: Boolean(row.configJson),
xiaofeixiaConfig: isXfxProviderCode(row.code)
? this.toPublicXiaofeixiaConfig(row.configJson)
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
}
@@ -0,0 +1,12 @@
import { Module, forwardRef } from '@nestjs/common';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { TradeModule } from '../trade/trade.module';
import { FulfillmentProviderService } from './fulfillment-provider.service';
import { FulfillmentService } from './fulfillment.service';
@Module({
imports: [IntegrationsModule, forwardRef(() => TradeModule)],
providers: [FulfillmentProviderService, FulfillmentService],
exports: [FulfillmentProviderService, FulfillmentService],
})
export class FulfillmentModule {}
@@ -0,0 +1,315 @@
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
import { isXfxProviderCode } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { CourierService } from '../../integrations/courier/courier.service';
import { CourierPayMode } from '../../integrations/courier/courier.types';
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
import { TradeService } from '../trade/trade.service';
import { FulfillmentProviderService } from './fulfillment-provider.service';
export type ManualShipInput = {
logisticsCompany: string;
trackingNo: string;
manualQueryUrl?: string;
};
export type HqLogisticsShipInput = ManualShipInput;
@Injectable()
export class FulfillmentService {
private readonly logger = new Logger(FulfillmentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly courier: CourierService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
@Inject(forwardRef(() => TradeService))
private readonly tradeService: TradeService,
) {}
async dispatchAfterPay(orderId: bigint) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
});
if (!order || order.payStatus !== 'PAID') return;
if (order.deliveryType === 'CROSS_CITY') {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
const warehouse = await this.resolveWarehouseForLocalOrder(order.cityId);
if (!warehouse) {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
await this.prisma.order.update({
where: { id: orderId },
data: { fulfillmentWarehouseId: warehouse.id },
});
if (warehouse.fulfillmentMode === 'MANUAL') {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
if (warehouse.fulfillmentMode === 'API_AUTO' && warehouse.fulfillmentProviderId) {
const provider = await this.prisma.fulfillmentProvider.findUnique({
where: { id: warehouse.fulfillmentProviderId },
});
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
await this.dispatchApiAuto(order, warehouse, provider);
}
}
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
if (!isXfxProviderCode(provider.code)) {
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
return;
}
let xfxConfig: XiaofeixiaConfig;
try {
xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(provider.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.logDispatchFailure(order, provider, message);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
return;
}
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665;
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757;
try {
const result = await this.courier.createShipment(
{
outNumber: order.orderNo,
from: {
name: warehouse.contactName,
mobile: warehouse.contactPhone,
address: warehouse.address,
addressDetail: warehouse.name,
coordinate: { lng: fromLng, lat: fromLat },
},
to: {
name: order.receiverName,
mobile: order.receiverPhone,
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
addressDetail: order.receiverAddress,
},
goodsName: order.productName,
goodsNum: order.quantity,
weight: 2,
payMode: CourierPayMode.SENDER,
remark: `仓配自动发货 ${order.orderNo}`,
},
{ xiaofeixia: xfxConfig },
);
const now = new Date();
await this.prisma.$transaction(async (tx) => {
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
const data = {
provider: 'XFX' as const,
fulfillmentProviderId: provider.id,
trackingNo: result.trackingNumber,
providerOrderNo: String(result.providerShipmentId),
shippingAt: now,
};
if (delivery) {
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
} else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
}
await tx.logThirdParty.create({
data: {
provider: 'XFX',
scene: 'ORDER_DISPATCH',
refType: 'ORDER',
refId: order.id,
externalNo: result.trackingNumber,
status: 'SUCCESS',
},
});
});
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', 'WAREHOUSE_AUTO');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.logDispatchFailure(order, provider, message);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
}
}
async shipManualByWarehouse(orderId: bigint, warehouseIds: bigint[], input: ManualShipInput) {
const order = await this.prisma.order.findFirst({
where: { id: orderId, fulfillmentWarehouseId: { in: warehouseIds } },
include: { delivery: true },
});
if (!order) throw new NotFoundException('订单不存在或无权操作');
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可发货');
}
const queryUrl =
input.manualQueryUrl?.trim() ||
(await this.buildQueryUrlFromTemplate(order.fulfillmentWarehouseId, input.trackingNo));
return this.applyManualShip(order, {
logisticsCompany: input.logisticsCompany.trim(),
trackingNo: input.trackingNo.trim(),
manualQueryUrl: queryUrl,
operator: 'WAREHOUSE_MANUAL',
});
}
async shipHqLogistics(orderId: bigint, input: HqLogisticsShipInput) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
});
if (!order) throw new NotFoundException('订单不存在');
// HQ 可对任意待发货单填快递单号(含仓配单手动填单)
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可发货');
}
if (order.delivery?.trackingNo) throw new BadRequestException('该订单已有运单号');
return this.applyManualShip(order, {
logisticsCompany: input.logisticsCompany.trim(),
trackingNo: input.trackingNo.trim(),
manualQueryUrl: input.manualQueryUrl?.trim(),
operator: 'HQ_LOGISTICS',
provider: 'LOGISTICS',
});
}
async getOrderTrack(orderId: bigint) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
});
if (!order?.delivery) {
return { nodes: [], manualQueryUrl: null };
}
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
try {
const options = order.delivery.fulfillmentProviderId
? {
xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig(
order.delivery.fulfillmentProviderId,
),
}
: undefined;
const nodes = await this.courier.getTrack(
{
trackingNumber: order.delivery.trackingNo ?? undefined,
outNumber: order.orderNo,
},
options,
);
return {
nodes,
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
logisticsCompany: order.delivery.logisticsCompany,
};
} catch {
// fall through
}
}
return {
nodes: [],
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
logisticsCompany: order.delivery.logisticsCompany,
};
}
private async applyManualShip(
order: Order & { delivery: { trackingNo: string | null } | null },
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },
) {
const now = new Date();
const provider = input.provider ?? 'MANUAL';
await this.prisma.$transaction(async (tx) => {
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
const data = {
provider,
logisticsCompany: input.logisticsCompany,
trackingNo: input.trackingNo,
manualQueryUrl: input.manualQueryUrl || null,
shippingAt: now,
};
if (delivery) {
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
} else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
}
});
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator);
return this.prisma.order.findUnique({
where: { id: order.id },
include: { delivery: true, fulfillmentWarehouse: true },
});
}
private async resolveWarehouseForLocalOrder(cityId: bigint) {
return this.prisma.cityWarehouse.findFirst({
where: { cityId, status: 'ACTIVE' },
orderBy: { createdAt: 'asc' },
});
}
private async ensureDeliveryRecord(
orderId: bigint,
provider: 'MANUAL' | 'LOGISTICS' | 'XFX',
fulfillmentProviderId?: bigint,
) {
const existing = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
if (existing) return;
await this.prisma.orderDelivery.create({
data: {
orderId,
provider,
...(fulfillmentProviderId ? { fulfillmentProviderId } : {}),
},
});
}
private async logDispatchFailure(order: Order, provider: FulfillmentProvider, error: string) {
await this.prisma.logThirdParty.create({
data: {
provider: 'XFX',
scene: 'ORDER_DISPATCH',
refType: 'ORDER',
refId: order.id,
status: 'FAILED',
errorMessage: `[${provider.code}] ${error}`.slice(0, 512),
},
});
}
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
if (!warehouseId) return undefined;
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
const tpl = wh?.manualQueryUrlTemplate;
if (!tpl) return undefined;
return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo));
}
}
@@ -231,6 +231,9 @@ export class AuthService {
isPrimary: number; isPrimary: number;
staffRole: string | null; staffRole: string | null;
permissions?: unknown; permissions?: unknown;
wxOpenId?: string | null;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
}, },
primary: { id: bigint; companyName: string | null }, primary: { id: bigint; companyName: string | null },
) { ) {
@@ -243,9 +246,37 @@ export class AuthService {
staffRole: account.staffRole ?? undefined, staffRole: account.staffRole ?? undefined,
companyName: primary.companyName ?? undefined, companyName: primary.companyName ?? undefined,
permissions: Array.isArray(account.permissions) ? account.permissions : undefined, permissions: Array.isArray(account.permissions) ? account.permissions : undefined,
hasWechat: !!account.wxOpenId,
wxNickname: account.wxNickname ?? undefined,
wxAvatarUrl: account.wxAvatarUrl ?? undefined,
}; };
} }
/** 公众号 snsapi_userinfo:写入合伙人微信昵称与头像 */
private async syncPartnerWechatProfile(
partnerAccountId: bigint,
accessToken: string | undefined,
openId: string,
) {
if (!accessToken) return null;
try {
const info = await this.wechatProvider.fetchOAuthUserInfo(accessToken, openId, {
refType: 'PARTNER',
refId: partnerAccountId,
});
const data: { wxNickname?: string; wxAvatarUrl?: string } = {};
if (info.nickname?.trim()) data.wxNickname = info.nickname.trim().slice(0, 64);
if (info.headImgUrl?.trim()) data.wxAvatarUrl = info.headImgUrl.trim().slice(0, 512);
if (!data.wxNickname && !data.wxAvatarUrl) return null;
return this.prisma.partnerAccount.update({
where: { id: partnerAccountId },
data,
});
} catch {
return null;
}
}
private async assertPartnerAccountByPhone(phone: string) { private async assertPartnerAccountByPhone(phone: string) {
const account = await this.prisma.partnerAccount.findUnique({ const account = await this.prisma.partnerAccount.findUnique({
where: { phone }, where: { phone },
@@ -1547,7 +1578,7 @@ export class AuthService {
throw new BadRequestException('该微信已绑定其他合伙人账号'); throw new BadRequestException('该微信已绑定其他合伙人账号');
} }
const updated = await this.prisma.partnerAccount.update({ let updated = await this.prisma.partnerAccount.update({
where: { id: partnerAccountId }, where: { id: partnerAccountId },
data: { data: {
wxOpenId: session.openId, wxOpenId: session.openId,
@@ -1555,6 +1586,12 @@ export class AuthService {
lastLoginAt: new Date(), lastLoginAt: new Date(),
}, },
}); });
const synced = await this.syncPartnerWechatProfile(
updated.id,
'accessToken' in session ? session.accessToken : undefined,
session.openId,
);
if (synced) updated = synced;
const primary = await this.resolvePrimaryAccount(updated.id); const primary = await this.resolvePrimaryAccount(updated.id);
this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform }); this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform });
@@ -1593,6 +1630,12 @@ export class AuthService {
lastLoginAt: new Date(), lastLoginAt: new Date(),
}, },
}); });
const synced = await this.syncPartnerWechatProfile(
account.id,
'accessToken' in session ? session.accessToken : undefined,
session.openId,
);
if (synced) account = synced;
const primary = await this.resolvePrimaryAccount(account.id); const primary = await this.resolvePrimaryAccount(account.id);
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform }); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform });
@@ -21,6 +21,7 @@ import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { StoreMembershipService } from '../../common/guards/store-membership.service'; import { StoreMembershipService } from '../../common/guards/store-membership.service';
@@ -54,6 +55,7 @@ import { StoreMembershipService } from '../../common/guards/store-membership.ser
OptionalJwtAuthGuard, OptionalJwtAuthGuard,
HqAuthGuard, HqAuthGuard,
PartnerPrimaryGuard, PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard, ShopStoreGuard,
], ],
exports: [ exports: [
@@ -68,6 +70,7 @@ import { StoreMembershipService } from '../../common/guards/store-membership.ser
OptionalJwtAuthGuard, OptionalJwtAuthGuard,
HqAuthGuard, HqAuthGuard,
PartnerPrimaryGuard, PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard, ShopStoreGuard,
], ],
}) })
@@ -1,240 +1,245 @@
import { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ClientApp, PartnerStaffRole, SmsScene } from '@dukang/shared-types'; import { ClientApp, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AnalyticsService } from '../analytics/analytics.service'; import { AnalyticsService } from '../analytics/analytics.service';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto'; import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
@Injectable() @Injectable()
export class PartnerStaffService { export class PartnerStaffService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly analytics: AnalyticsService, private readonly analytics: AnalyticsService,
private readonly authService: AuthService, private readonly authService: AuthService,
) {} ) {}
async listStaff(parentAccountId: bigint) { async listStaff(parentAccountId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({ const rows = await this.prisma.partnerAccount.findMany({
where: { parentAccountId }, where: { parentAccountId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
return rows.map((row) => this.toStaffItem(row)); return rows.map((row) => this.toStaffItem(row));
} }
async sendStaffPhoneSms(actor: AuthUser, phone: string) { async sendStaffPhoneSms(actor: AuthUser, phone: string) {
const parentAccountId = actor.actorId; const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({ const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId }, where: { id: parentAccountId },
}); });
if (parent.isPrimary !== 1) { if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号'); throw new BadRequestException('仅主账号可添加子账号');
} }
const normalized = phone.trim(); const normalized = phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalized)) { if (!/^1[3-9]\d{9}$/.test(normalized)) {
throw new BadRequestException('请输入正确的手机号码'); throw new BadRequestException('请输入正确的手机号码');
} }
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } }); const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
if (existing) throw new BadRequestException('该手机号已被使用'); if (existing) throw new BadRequestException('该手机号已被使用');
const masked = this.maskPhone(normalized); const masked = this.maskPhone(normalized);
try { try {
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, { await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
clientApp: ClientApp.PARTNER_H5, clientApp: ClientApp.PARTNER_H5,
}); });
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, { this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
phone: masked, phone: masked,
scene: SmsScene.PARTNER_STAFF_ADD, scene: SmsScene.PARTNER_STAFF_ADD,
status: 'success', status: 'success',
}); });
} catch (err) { } catch (err) {
if (err instanceof BadRequestException) { if (err instanceof BadRequestException) {
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, { this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
phone: masked, phone: masked,
scene: SmsScene.PARTNER_STAFF_ADD, scene: SmsScene.PARTNER_STAFF_ADD,
status: 'failed', status: 'failed',
reason: err.message, reason: err.message,
}); });
} }
throw err; throw err;
} }
return { ok: true, maskedPhone: masked }; return { ok: true, maskedPhone: masked };
} }
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) { async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
const parentAccountId = actor.actorId; const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({ const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId }, where: { id: parentAccountId },
}); });
if (parent.isPrimary !== 1) { if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号'); throw new BadRequestException('仅主账号可添加子账号');
} }
const phone = dto.phone.trim(); const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) { if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码'); throw new BadRequestException('请输入正确的手机号码');
} }
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } }); const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已被使用'); if (existing) throw new BadRequestException('该手机号已被使用');
const smsCode = dto.smsCode.trim(); const smsCode = dto.smsCode.trim();
if (!smsCode) throw new BadRequestException('请输入手机号验证码'); if (!smsCode) throw new BadRequestException('请输入手机号验证码');
try { try {
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD); await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD);
} catch (err) { } catch (err) {
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, { this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
phone: this.maskPhone(phone), phone: this.maskPhone(phone),
reason: err instanceof BadRequestException ? err.message : '验证码错误', reason: err instanceof BadRequestException ? err.message : '验证码错误',
}); });
throw err; throw err;
} }
const name = dto.name.trim(); const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名'); if (!name) throw new BadRequestException('请填写真实姓名');
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL; const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
const permissions =
const account = await this.prisma.partnerAccount.create({ dto.permissions && dto.permissions.length > 0
data: { ? dto.permissions
phone, : [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS];
name,
staffRole, const account = await this.prisma.partnerAccount.create({
permissions: dto.permissions ?? undefined, data: {
isPrimary: 0, phone,
parentAccountId: parent.id, name,
status: 'DISABLED', staffRole,
}, permissions,
}); isPrimary: 0,
parentAccountId: parent.id,
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, { status: 'DISABLED',
name, },
phone: this.maskPhone(phone), });
staffRole,
status: account.status, this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
phoneVerified: true, name,
}); phone: this.maskPhone(phone),
staffRole,
return this.toStaffItem(account); permissions,
} status: account.status,
phoneVerified: true,
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) { });
const parentAccountId = actor.actorId;
const staff = await this.assertStaffOwned(parentAccountId, staffId); return this.toStaffItem(account);
const before = { }
name: staff.name,
staffRole: staff.staffRole, async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
status: staff.status, const parentAccountId = actor.actorId;
}; const staff = await this.assertStaffOwned(parentAccountId, staffId);
const data: Record<string, unknown> = {}; const before = {
if (dto.name !== undefined) { name: staff.name,
const name = dto.name.trim(); staffRole: staff.staffRole,
if (!name) throw new BadRequestException('请填写真实姓名'); status: staff.status,
data.name = name; };
} const data: Record<string, unknown> = {};
if (dto.staffRole !== undefined) { if (dto.name !== undefined) {
data.staffRole = dto.staffRole as PartnerStaffRole; const name = dto.name.trim();
} if (!name) throw new BadRequestException('请填写真实姓名');
if (dto.permissions !== undefined) { data.name = name;
data.permissions = dto.permissions; }
} if (dto.staffRole !== undefined) {
if (dto.status !== undefined) { data.staffRole = dto.staffRole as PartnerStaffRole;
data.status = dto.status; }
} if (dto.permissions !== undefined) {
const updated = await this.prisma.partnerAccount.update({ data.permissions = dto.permissions;
where: { id: staff.id }, }
data, if (dto.status !== undefined) {
}); data.status = dto.status;
}
const onlyRoleChange = const updated = await this.prisma.partnerAccount.update({
(dto.staffRole !== undefined || dto.permissions !== undefined) && where: { id: staff.id },
dto.name === undefined && data,
dto.status === undefined; });
const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
const onlyRoleChange =
const primaryId = parentAccountId; (dto.staffRole !== undefined || dto.permissions !== undefined) &&
this.trackStaffEvent(actor, primaryId, eventName, staff.id, { dto.name === undefined &&
before, dto.status === undefined;
after: { const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
name: updated.name,
staffRole: updated.staffRole, const primaryId = parentAccountId;
status: updated.status, this.trackStaffEvent(actor, primaryId, eventName, staff.id, {
}, before,
}); after: {
name: updated.name,
return this.toStaffItem(updated); staffRole: updated.staffRole,
} status: updated.status,
},
async deleteStaff(actor: AuthUser, staffId: bigint) { });
const parentAccountId = actor.actorId;
const staff = await this.assertStaffOwned(parentAccountId, staffId); return this.toStaffItem(updated);
}
this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
name: staff.name, async deleteStaff(actor: AuthUser, staffId: bigint) {
phone: this.maskPhone(staff.phone), const parentAccountId = actor.actorId;
staffRole: staff.staffRole, const staff = await this.assertStaffOwned(parentAccountId, staffId);
status: staff.status,
}); this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
name: staff.name,
await this.prisma.partnerAccount.delete({ where: { id: staff.id } }); phone: this.maskPhone(staff.phone),
return { ok: true }; staffRole: staff.staffRole,
} status: staff.status,
});
private trackStaffEvent(
actor: AuthUser, await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
primaryAccountId: bigint, return { ok: true };
eventName: string, }
refId: bigint,
extraJson?: Record<string, unknown>, private trackStaffEvent(
) { actor: AuthUser,
this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, { primaryAccountId: bigint,
partnerAccountId: primaryAccountId, eventName: string,
eventName, refId: bigint,
refType: 'PARTNER_ACCOUNT', extraJson?: Record<string, unknown>,
refId, ) {
extraJson, this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, {
}); partnerAccountId: primaryAccountId,
} eventName,
refType: 'PARTNER_ACCOUNT',
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) { refId,
const staff = await this.prisma.partnerAccount.findFirst({ extraJson,
where: { id: staffId, parentAccountId }, });
}); }
if (!staff) throw new NotFoundException('子账号不存在');
return staff; private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
} const staff = await this.prisma.partnerAccount.findFirst({
where: { id: staffId, parentAccountId },
private toStaffItem(row: { });
id: bigint; if (!staff) throw new NotFoundException('子账号不存在');
name: string; return staff;
phone: string; }
staffRole: string | null;
permissions?: unknown; private toStaffItem(row: {
status: string; id: bigint;
lastLoginAt: Date | null; name: string;
}) { phone: string;
return serializeBigInt({ staffRole: string | null;
id: row.id.toString(), permissions?: unknown;
name: row.name, status: string;
phone: this.maskPhone(row.phone), lastLoginAt: Date | null;
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL, }) {
permissions: Array.isArray(row.permissions) ? row.permissions : undefined, return serializeBigInt({
status: row.status, id: row.id.toString(),
lastLoginAt: row.lastLoginAt?.toISOString(), name: row.name,
}); phone: this.maskPhone(row.phone),
} staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
private maskPhone(phone: string): string { status: row.status,
if (phone.length !== 11) return phone; lastLoginAt: row.lastLoginAt?.toISOString(),
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`; });
} }
}
private maskPhone(phone: string): string {
if (phone.length !== 11) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
}
}
@@ -5,7 +5,22 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
import { CityWarehouseService } from '../city-scope/city-warehouse.service'; import { CityWarehouseService } from '../city-scope/city-warehouse.service';
import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto'; import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto';
import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto'; import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto';
import type { WarehouseManagerType, WarehouseStatus } from '@prisma/client'; import type {
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
} from '@prisma/client';
function mapWarehouseFulfillment(dto: CreateCityWarehouseDto | UpdateCityWarehouseDto) {
return {
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
fulfillmentProviderId: dto.fulfillmentProviderId ? BigInt(dto.fulfillmentProviderId) : undefined,
manualCarrierLabel: dto.manualCarrierLabel ?? undefined,
manualQueryUrlTemplate: dto.manualQueryUrlTemplate ?? undefined,
lng: dto.lng ?? undefined,
lat: dto.lat ?? undefined,
};
}
@Controller('admin/cities/:cityId/warehouses') @Controller('admin/cities/:cityId/warehouses')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard)
@@ -33,6 +48,7 @@ export class AdminCityWarehousesController {
managerType: dto.managerType as WarehouseManagerType, managerType: dto.managerType as WarehouseManagerType,
partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined, partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined,
status: dto.status as WarehouseStatus | undefined, status: dto.status as WarehouseStatus | undefined,
...mapWarehouseFulfillment(dto),
}); });
} }
} }
@@ -63,11 +79,22 @@ export class AdminCityWarehouseMutationsController {
managerType: dto.managerType as WarehouseManagerType | undefined, managerType: dto.managerType as WarehouseManagerType | undefined,
partnerAccountId: partnerAccountId:
dto.partnerAccountId === null dto.partnerAccountId === null
? undefined ? null
: dto.partnerAccountId : dto.partnerAccountId
? BigInt(dto.partnerAccountId) ? BigInt(dto.partnerAccountId)
: undefined, : undefined,
status: dto.status as WarehouseStatus | undefined, status: dto.status as WarehouseStatus | undefined,
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
fulfillmentProviderId:
dto.fulfillmentProviderId === null
? null
: dto.fulfillmentProviderId
? BigInt(dto.fulfillmentProviderId)
: undefined,
manualCarrierLabel: dto.manualCarrierLabel,
manualQueryUrlTemplate: dto.manualQueryUrlTemplate,
lng: dto.lng,
lat: dto.lat,
}); });
} }
@@ -22,6 +22,7 @@ export class AdminDashboardService {
deliveriesTotal, deliveriesTotal,
pendingPayouts, pendingPayouts,
pendingBills, pendingBills,
pendingPartnerDraftBills,
openTickets, openTickets,
] = await Promise.all([ ] = await Promise.all([
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }), this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
@@ -42,7 +43,8 @@ export class AdminDashboardService {
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }), this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.orderDelivery.count(), this.prisma.orderDelivery.count(),
this.prisma.storePayout.count({ where: { status: 'PENDING' } }), this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
this.prisma.partnerBill.count({ where: { status: { in: ['DRAFT', 'CONFIRMED'] } } }), this.prisma.partnerBill.count({ where: { status: 'CONFIRMED' } }),
this.prisma.partnerBill.count({ where: { status: 'DRAFT' } }),
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }), this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
]); ]);
@@ -57,7 +59,9 @@ export class AdminDashboardService {
redeemToday, redeemToday,
deliveriesTotal, deliveriesTotal,
pendingPayouts, pendingPayouts,
/** 合伙人已确认、待总部审核打款 */
pendingBills, pendingBills,
pendingPartnerDraftBills,
openTickets, openTickets,
ordersByStatus: ordersByStatus.map((row) => ({ ordersByStatus: ordersByStatus.map((row) => ({
status: row.status, status: row.status,
@@ -0,0 +1,68 @@
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import {
CreateFulfillmentProviderDto,
UpdateFulfillmentProviderDto,
} from './dto/admin-mutate.dto';
import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
@Controller('admin/fulfillment-providers')
@UseGuards(HqAuthGuard)
export class AdminFulfillmentProvidersController {
constructor(private readonly service: FulfillmentProviderService) {}
@Get()
list() {
return this.service.listAll();
}
@Get('active-api')
listActiveApi() {
return this.service.listActiveApiProviders();
}
@Post()
@HqOperation({
action: HqOperationAction.WAREHOUSE_UPDATE,
refType: 'FULFILLMENT_PROVIDER',
refIdField: 'id',
includeBody: true,
})
create(@Body() dto: CreateFulfillmentProviderDto) {
return this.service.create({
code: dto.code,
name: dto.name,
type: dto.type as FulfillmentProviderType,
status: dto.status as FulfillmentProviderStatus | undefined,
configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.getById(BigInt(id));
}
@Put(':id')
@HqOperation({
action: HqOperationAction.WAREHOUSE_UPDATE,
refType: 'FULFILLMENT_PROVIDER',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateFulfillmentProviderDto) {
return this.service.update(BigInt(id), {
name: dto.name,
type: dto.type as FulfillmentProviderType | undefined,
status: dto.status as FulfillmentProviderStatus | undefined,
configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig,
});
}
}
@@ -4,7 +4,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminOrdersService } from './admin-orders.service'; import { AdminOrdersService } from './admin-orders.service';
import { AdminShipOrderDto, BatchDeleteOrdersDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto'; import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
import { AdminOrdersQueryDto } from './dto/admin-query.dto'; import { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Controller('admin/orders') @Controller('admin/orders')
@@ -51,6 +51,17 @@ export class AdminOrdersController {
return this.ordersService.shipOrder(BigInt(id), dto); return this.ordersService.shipOrder(BigInt(id), dto);
} }
@Post(':id/logistics-ship')
@HqOperation({
action: HqOperationAction.ORDER_SHIP,
refType: 'ORDER',
refIdParam: 'id',
includeBody: true,
})
shipLogistics(@Param('id') id: string, @Body() dto: HqLogisticsShipDto) {
return this.ordersService.shipLogistics(BigInt(id), dto);
}
/** preV1 调试:直接改订单状态,不走业务校验 */ /** preV1 调试:直接改订单状态,不走业务校验 */
@Put(':id/status') @Put(':id/status')
@HqOperation({ @HqOperation({
@@ -1,5 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -8,8 +7,10 @@ import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-comp
import { TradeService } from '../trade/trade.service'; import { TradeService } from '../trade/trade.service';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service'; import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import type { AdminOrdersQueryDto } from './dto/admin-query.dto'; import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
import type { AdminShipOrderDto } from './dto/admin-mutate.dto'; import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
@Injectable() @Injectable()
export class AdminOrdersService { export class AdminOrdersService {
@@ -17,7 +18,8 @@ export class AdminOrdersService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly tradeService: TradeService, private readonly tradeService: TradeService,
private readonly xiaofeixiaService: AdminXiaofeixiaService, private readonly xiaofeixiaService: AdminXiaofeixiaService,
private readonly config: ConfigService, private readonly fulfillmentService: FulfillmentService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
) {} ) {}
async list(query: AdminOrdersQueryDto) { async list(query: AdminOrdersQueryDto) {
@@ -28,6 +30,7 @@ export class AdminOrdersService {
if (query.orderNo) where.orderNo = { contains: query.orderNo }; if (query.orderNo) where.orderNo = { contains: query.orderNo };
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals']; if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
if (query.userId) where.userId = BigInt(query.userId); if (query.userId) where.userId = BigInt(query.userId);
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone }; if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
if (query.createdFrom || query.createdTo) { if (query.createdFrom || query.createdTo) {
where.createdAt = {}; where.createdAt = {};
@@ -44,6 +47,8 @@ export class AdminOrdersService {
include: { include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } },
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } }, delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
city: { select: { id: true, name: true, code: true } },
fulfillmentWarehouse: { select: { id: true, name: true } },
}, },
}), }),
this.prisma.order.count({ where }), this.prisma.order.count({ where }),
@@ -73,6 +78,18 @@ export class AdminOrdersService {
city: { select: { id: true, name: true, code: true } }, city: { select: { id: true, name: true, code: true } },
product: { select: { id: true, name: true, skuCode: true, barcode69: true } }, product: { select: { id: true, name: true, skuCode: true, barcode69: true } },
imageResource: { select: { id: true, url: true } }, imageResource: { select: { id: true, url: true } },
fulfillmentWarehouse: {
select: {
id: true,
name: true,
contactName: true,
contactPhone: true,
address: true,
lng: true,
lat: true,
fulfillmentMode: true,
},
},
}, },
}); });
if (!order) throw new NotFoundException('订单不存在'); if (!order) throw new NotFoundException('订单不存在');
@@ -94,29 +111,17 @@ export class AdminOrdersService {
return this.detail(id); return this.detail(id);
} }
getShipDefaults() {
return {
provider: 'XFX',
providerLabel: '小飞侠',
fromName: this.config.get<string>('SHIP_FROM_NAME') || '杜康仓库',
fromMobile: this.config.get<string>('SHIP_FROM_MOBILE') || '13800000000',
fromAddress: this.config.get<string>('SHIP_FROM_ADDRESS') || '河南省郑州市金水区',
fromAddressDetail: this.config.get<string>('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓',
fromLng: Number(this.config.get<string>('SHIP_FROM_LNG') || 113.665),
fromLat: Number(this.config.get<string>('SHIP_FROM_LAT') || 34.757),
weight: 2,
payMode: '1',
};
}
async shipOrder(id: bigint, dto: AdminShipOrderDto) { async shipOrder(id: bigint, dto: AdminShipOrderDto) {
if (dto.provider !== 'XFX') { if (dto.provider !== 'XFX') {
throw new BadRequestException('暂仅支持小飞侠配送'); throw new BadRequestException('暂仅支持小飞侠配送');
} }
const order = await this.prisma.order.findUnique({ let order = await this.prisma.order.findUnique({
where: { id }, where: { id },
include: { delivery: true }, include: {
delivery: true,
fulfillmentWarehouse: true,
},
}); });
if (!order) throw new NotFoundException('订单不存在'); if (!order) throw new NotFoundException('订单不存在');
@@ -127,7 +132,46 @@ export class AdminOrdersService {
throw new BadRequestException('该订单已有运单号,请勿重复发货'); throw new BadRequestException('该订单已有运单号,请勿重复发货');
} }
const defaults = this.getShipDefaults(); if (dto.warehouseId) {
const warehouseId = BigInt(dto.warehouseId);
const warehouseRow = await this.prisma.cityWarehouse.findFirst({
where: { id: warehouseId, status: 'ACTIVE' },
});
if (!warehouseRow) {
throw new BadRequestException('仓库不存在或已停用');
}
if (order.fulfillmentWarehouseId !== warehouseId) {
await this.prisma.order.update({
where: { id },
data: { fulfillmentWarehouseId: warehouseId },
});
order = await this.prisma.order.findUniqueOrThrow({
where: { id },
include: { delivery: true, fulfillmentWarehouse: true },
});
}
}
const warehouse = order.fulfillmentWarehouse;
if (!warehouse) {
throw new BadRequestException('请先选择履约仓库');
}
const providerId =
order.delivery?.fulfillmentProviderId ??
warehouse?.fulfillmentProviderId ??
null;
let xfxConfig;
if (providerId) {
xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(providerId);
} else {
xfxConfig = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
if (!xfxConfig) {
throw new BadRequestException('请先在仓配管理中注册并配置小飞侠承运商');
}
}
const defaults = this.getShipDefaults(warehouse);
const shipmentDto: XiaofeixiaCreateShipmentDto = { const shipmentDto: XiaofeixiaCreateShipmentDto = {
outNumber: order.orderNo, outNumber: order.orderNo,
fromName: dto.fromName || defaults.fromName, fromName: dto.fromName || defaults.fromName,
@@ -147,13 +191,14 @@ export class AdminOrdersService {
remark: dto.remark || `HQ发货 ${order.orderNo}`, remark: dto.remark || `HQ发货 ${order.orderNo}`,
}; };
const result = await this.xiaofeixiaService.createShipment(shipmentDto); const result = await this.xiaofeixiaService.createShipment(shipmentDto, xfxConfig);
if (!result.ok || !result.data) { if (!result.ok || !result.data) {
throw new BadRequestException(result.error || '小飞侠创建运单失败'); throw new BadRequestException(result.error || '小飞侠创建运单失败');
} }
const { providerShipmentId, trackingNumber } = result.data; const { providerShipmentId, trackingNumber } = result.data;
const now = new Date(); const now = new Date();
const resolvedProviderId = providerId;
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
if (order.delivery) { if (order.delivery) {
@@ -161,6 +206,7 @@ export class AdminOrdersService {
where: { orderId: id }, where: { orderId: id },
data: { data: {
provider: 'XFX', provider: 'XFX',
fulfillmentProviderId: resolvedProviderId,
trackingNo: trackingNumber, trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId), providerOrderNo: String(providerShipmentId),
shippingAt: now, shippingAt: now,
@@ -171,6 +217,7 @@ export class AdminOrdersService {
data: { data: {
orderId: id, orderId: id,
provider: 'XFX', provider: 'XFX',
fulfillmentProviderId: resolvedProviderId,
trackingNo: trackingNumber, trackingNo: trackingNumber,
providerOrderNo: String(providerShipmentId), providerOrderNo: String(providerShipmentId),
shippingAt: now, shippingAt: now,
@@ -183,6 +230,46 @@ export class AdminOrdersService {
return this.detail(id); return this.detail(id);
} }
getShipDefaults(warehouse?: {
contactName: string;
contactPhone: string;
address: string;
name: string;
lng: { toNumber?: () => number } | number | null;
lat: { toNumber?: () => number } | number | null;
} | null) {
const lng =
warehouse?.lng != null
? typeof warehouse.lng === 'object' && warehouse.lng && 'toNumber' in warehouse.lng
? Number(warehouse.lng)
: Number(warehouse.lng)
: 113.665;
const lat =
warehouse?.lat != null
? typeof warehouse.lat === 'object' && warehouse.lat && 'toNumber' in warehouse.lat
? Number(warehouse.lat)
: Number(warehouse.lat)
: 34.757;
return {
provider: 'XFX',
providerLabel: '小飞侠',
fromName: warehouse?.contactName || '杜康仓库',
fromMobile: warehouse?.contactPhone || '13800000000',
fromAddress: warehouse?.address || '河南省郑州市金水区',
fromAddressDetail: warehouse?.name || '杜康酒业仓',
fromLng: lng,
fromLat: lat,
weight: 2,
payMode: '1',
};
}
/** 总部传统快递填单(同城无仓 / 跨城) */
async shipLogistics(id: bigint, dto: HqLogisticsShipDto) {
await this.fulfillmentService.shipHqLogistics(id, dto);
return this.detail(id);
}
async batchDeleteOrders(ids: bigint[]) { async batchDeleteOrders(ids: bigint[]) {
const uniqueIds = [...new Set(ids)]; const uniqueIds = [...new Set(ids)];
if (!uniqueIds.length) { if (!uniqueIds.length) {

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